Noise reduction beta

This commit is contained in:
2026-03-03 02:01:42 +01:00
parent 50e7a66812
commit d684fc5632
14 changed files with 1516 additions and 159 deletions

View File

@@ -26,7 +26,9 @@
>
<ng-icon name="lucideUsers" class="w-4 h-4" />
<span>Users</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{ onlineUsers().length }}</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{
onlineUsers().length
}}</span>
</button>
</div>
</div>
@@ -37,9 +39,15 @@
<!-- Text Channels -->
<div class="p-3">
<div class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Text Channels</h4>
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">
Text Channels
</h4>
@if (canManageChannels()) {
<button (click)="createChannel('text')" class="text-muted-foreground hover:text-foreground transition-colors" title="Create Text Channel">
<button
(click)="createChannel('text')"
class="text-muted-foreground hover:text-foreground transition-colors"
title="Create Text Channel"
>
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
</button>
}
@@ -80,9 +88,15 @@
<!-- Voice Channels -->
<div class="p-3 pt-0">
<div class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Voice Channels</h4>
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">
Voice Channels
</h4>
@if (canManageChannels()) {
<button (click)="createChannel('voice')" class="text-muted-foreground hover:text-foreground transition-colors" title="Create Voice Channel">
<button
(click)="createChannel('voice')"
class="text-muted-foreground hover:text-foreground transition-colors"
title="Create Voice Channel"
>
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
</button>
}
@@ -130,9 +144,29 @@
[name]="u.displayName"
[avatarUrl]="u.avatarUrl"
size="xs"
[ringClass]="u.voiceState?.isDeafened ? 'ring-2 ring-red-500' : u.voiceState?.isMuted ? 'ring-2 ring-yellow-500' : voiceActivity.isSpeaking(u.oderId || u.id)() ? 'ring-2 ring-green-400 shadow-[0_0_8px_2px_rgba(74,222,128,0.6)]' : 'ring-2 ring-green-500/40'"
[ringClass]="
u.voiceState?.isDeafened
? 'ring-2 ring-red-500'
: u.voiceState?.isMuted
? 'ring-2 ring-yellow-500'
: voiceActivity.isSpeaking(u.oderId || u.id)()
? 'ring-2 ring-green-400 shadow-[0_0_8px_2px_rgba(74,222,128,0.6)]'
: 'ring-2 ring-green-500/40'
"
/>
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
<span class="text-sm text-foreground/80 truncate flex-1">{{
u.displayName
}}</span>
<!-- Ping latency indicator -->
@if (u.id !== currentUser()?.id) {
<span
class="w-2 h-2 rounded-full shrink-0"
[class]="getPingColorClass(u)"
[title]="
getPeerLatency(u) !== null ? getPeerLatency(u) + ' ms' : 'Measuring...'
"
></span>
}
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
<button
(click)="viewStream(u.id); $event.stopPropagation()"
@@ -161,11 +195,19 @@
<!-- Current User (You) -->
@if (currentUser()) {
<div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">
You
</h4>
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
<div class="relative">
<app-user-avatar [name]="currentUser()?.displayName || '?'" [avatarUrl]="currentUser()?.avatarUrl" size="sm" />
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
<app-user-avatar
[name]="currentUser()?.displayName || '?'"
[avatarUrl]="currentUser()?.avatarUrl"
size="sm"
/>
<span
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"
></span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p>
@@ -176,8 +218,13 @@
In voice
</p>
}
@if (currentUser()?.screenShareState?.isSharing || (currentUser()?.id && isUserSharing(currentUser()!.id))) {
<span class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium flex items-center gap-1 animate-pulse">
@if (
currentUser()?.screenShareState?.isSharing ||
(currentUser()?.id && isUserSharing(currentUser()!.id))
) {
<span
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium flex items-center gap-1 animate-pulse"
>
<ng-icon name="lucideMonitor" class="w-2.5 h-2.5" />
LIVE
</span>
@@ -201,18 +248,33 @@
(contextmenu)="openUserContextMenu($event, user)"
>
<div class="relative">
<app-user-avatar [name]="user.displayName" [avatarUrl]="user.avatarUrl" size="sm" />
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
<app-user-avatar
[name]="user.displayName"
[avatarUrl]="user.avatarUrl"
size="sm"
/>
<span
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"
></span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
@if (user.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
<span
class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium"
>Owner</span
>
} @else if (user.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
<span
class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium"
>Admin</span
>
} @else if (user.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
<span
class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium"
>Mod</span
>
}
</div>
<div class="flex items-center gap-2">
@@ -258,7 +320,12 @@
<!-- Channel context menu -->
@if (showChannelMenu()) {
<app-context-menu [x]="channelMenuX()" [y]="channelMenuY()" (closed)="closeChannelMenu()" [width]="'w-44'">
<app-context-menu
[x]="channelMenuX()"
[y]="channelMenuY()"
(closed)="closeChannelMenu()"
[width]="'w-44'"
>
<button (click)="resyncMessages()" class="context-menu-item">Resync Messages</button>
@if (canManageChannels()) {
<div class="context-menu-divider"></div>
@@ -273,15 +340,25 @@
<app-context-menu [x]="userMenuX()" [y]="userMenuY()" (closed)="closeUserMenu()">
@if (isAdmin()) {
@if (contextMenuUser()?.role === 'member') {
<button (click)="changeUserRole('moderator')" class="context-menu-item">Promote to Moderator</button>
<button (click)="changeUserRole('admin')" class="context-menu-item">Promote to Admin</button>
<button (click)="changeUserRole('moderator')" class="context-menu-item">
Promote to Moderator
</button>
<button (click)="changeUserRole('admin')" class="context-menu-item">
Promote to Admin
</button>
}
@if (contextMenuUser()?.role === 'moderator') {
<button (click)="changeUserRole('admin')" class="context-menu-item">Promote to Admin</button>
<button (click)="changeUserRole('member')" class="context-menu-item">Demote to Member</button>
<button (click)="changeUserRole('admin')" class="context-menu-item">
Promote to Admin
</button>
<button (click)="changeUserRole('member')" class="context-menu-item">
Demote to Member
</button>
}
@if (contextMenuUser()?.role === 'admin') {
<button (click)="changeUserRole('member')" class="context-menu-item">Demote to Member</button>
<button (click)="changeUserRole('member')" class="context-menu-item">
Demote to Member
</button>
}
<div class="context-menu-divider"></div>
<button (click)="kickUserAction()" class="context-menu-item-danger">Kick User</button>

View File

@@ -3,9 +3,27 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus } from '@ng-icons/lucide';
import { selectOnlineUsers, selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { selectCurrentRoom, selectActiveChannelId, selectTextChannels, selectVoiceChannels } from '../../../store/rooms/rooms.selectors';
import {
lucideMessageSquare,
lucideMic,
lucideMicOff,
lucideChevronLeft,
lucideMonitor,
lucideHash,
lucideUsers,
lucidePlus,
} from '@ng-icons/lucide';
import {
selectOnlineUsers,
selectCurrentUser,
selectIsCurrentUserAdmin,
} from '../../../store/users/users.selectors';
import {
selectCurrentRoom,
selectActiveChannelId,
selectTextChannels,
selectVoiceChannels,
} from '../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../store/users/users.actions';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { MessagesActions } from '../../../store/messages/messages.actions';
@@ -22,9 +40,26 @@ type TabView = 'channels' | 'users';
@Component({
selector: 'app-rooms-side-panel',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent, ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent],
imports: [
CommonModule,
FormsModule,
NgIcon,
VoiceControlsComponent,
ContextMenuComponent,
UserAvatarComponent,
ConfirmDialogComponent,
],
viewProviders: [
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus })
provideIcons({
lucideMessageSquare,
lucideMic,
lucideMicOff,
lucideChevronLeft,
lucideMonitor,
lucideHash,
lucideUsers,
lucidePlus,
}),
],
templateUrl: './rooms-side-panel.component.html',
})
@@ -73,7 +108,9 @@ export class RoomsSidePanelComponent {
const current = this.currentUser();
const currentId = current?.id;
const currentOderId = current?.oderId;
return this.onlineUsers().filter(user => user.id !== currentId && user.oderId !== currentOderId);
return this.onlineUsers().filter(
(user) => user.id !== currentId && user.oderId !== currentOderId,
);
}
/** Check whether the current user has permission to manage channels. */
@@ -264,10 +301,18 @@ export class RoomsSidePanelComponent {
if (!this.webrtc.isVoiceConnected()) {
// Stale state clear it so the user can proceed
if (current.id) {
this.store.dispatch(UsersActions.updateVoiceState({
userId: current.id,
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
}));
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined,
},
}),
);
}
} else {
// Already connected to voice in another server; must disconnect first
@@ -276,47 +321,64 @@ export class RoomsSidePanelComponent {
}
// If switching channels within the same server, just update the room
const isSwitchingChannels = current?.voiceState?.isConnected &&
current.voiceState.serverId === room?.id &&
current.voiceState.roomId !== roomId;
const isSwitchingChannels =
current?.voiceState?.isConnected &&
current.voiceState.serverId === room?.id &&
current.voiceState.roomId !== roomId;
// Enable microphone and broadcast voice-state
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
enableVoicePromise.then(() => {
if (current?.id && room) {
this.store.dispatch(UsersActions.updateVoiceState({
userId: current.id,
voiceState: { isConnected: true, isMuted: current.voiceState?.isMuted ?? false, isDeafened: current.voiceState?.isDeafened ?? false, roomId: roomId, serverId: room.id }
}));
}
// Start voice heartbeat to broadcast presence every 5 seconds
this.webrtc.startVoiceHeartbeat(roomId, room?.id);
this.webrtc.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
displayName: current?.displayName || 'User',
voiceState: { isConnected: true, isMuted: current?.voiceState?.isMuted ?? false, isDeafened: current?.voiceState?.isDeafened ?? false, roomId: roomId, serverId: room?.id }
});
// Update voice session for floating controls
if (room) {
// Find label from channel list
const voiceChannel = this.voiceChannels().find(channel => channel.id === roomId);
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
this.voiceSessionService.startSession({
serverId: room.id,
serverName: room.name,
roomId: roomId,
roomName: voiceRoomName,
serverIcon: room.icon,
serverDescription: room.description,
serverRoute: `/room/${room.id}`,
enableVoicePromise
.then(() => {
if (current?.id && room) {
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: true,
isMuted: current.voiceState?.isMuted ?? false,
isDeafened: current.voiceState?.isDeafened ?? false,
roomId: roomId,
serverId: room.id,
},
}),
);
}
// Start voice heartbeat to broadcast presence every 5 seconds
this.webrtc.startVoiceHeartbeat(roomId, room?.id);
this.webrtc.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
displayName: current?.displayName || 'User',
voiceState: {
isConnected: true,
isMuted: current?.voiceState?.isMuted ?? false,
isDeafened: current?.voiceState?.isDeafened ?? false,
roomId: roomId,
serverId: room?.id,
},
});
}
}).catch((_error) => {
// Failed to join voice room
});
// Update voice session for floating controls
if (room) {
// Find label from channel list
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
this.voiceSessionService.startSession({
serverId: room.id,
serverName: room.name,
roomId: roomId,
roomName: voiceRoomName,
serverIcon: room.icon,
serverDescription: room.description,
serverRoute: `/room/${room.id}`,
});
}
})
.catch((_error) => {
// Failed to join voice room
});
}
/** Leave a voice channel and broadcast the disconnect state. */
@@ -333,10 +395,18 @@ export class RoomsSidePanelComponent {
// Update store voice state
if (current?.id) {
this.store.dispatch(UsersActions.updateVoiceState({
userId: current.id,
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
}));
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined,
},
}),
);
}
// Broadcast disconnect
@@ -344,7 +414,13 @@ export class RoomsSidePanelComponent {
type: 'voice-state',
oderId: current?.oderId || current?.id,
displayName: current?.displayName || 'User',
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined,
},
});
// End voice session
@@ -355,10 +431,11 @@ export class RoomsSidePanelComponent {
voiceOccupancy(roomId: string): number {
const users = this.onlineUsers();
const room = this.currentRoom();
return users.filter(user =>
!!user.voiceState?.isConnected &&
user.voiceState?.roomId === roomId &&
user.voiceState?.serverId === room?.id
return users.filter(
(user) =>
!!user.voiceState?.isConnected &&
user.voiceState?.roomId === roomId &&
user.voiceState?.serverId === room?.id,
).length;
}
@@ -380,7 +457,9 @@ export class RoomsSidePanelComponent {
if (me?.id === userId) {
return this.webrtc.isScreenSharing();
}
const user = this.onlineUsers().find(onlineUser => onlineUser.id === userId || onlineUser.oderId === userId);
const user = this.onlineUsers().find(
(onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId,
);
if (user?.screenShareState?.isSharing === false) {
return false;
}
@@ -391,10 +470,11 @@ export class RoomsSidePanelComponent {
/** Return all users currently connected to a specific voice channel. */
voiceUsersInRoom(roomId: string) {
const room = this.currentRoom();
return this.onlineUsers().filter(user =>
!!user.voiceState?.isConnected &&
user.voiceState?.roomId === roomId &&
user.voiceState?.serverId === room?.id
return this.onlineUsers().filter(
(user) =>
!!user.voiceState?.isConnected &&
user.voiceState?.roomId === roomId &&
user.voiceState?.serverId === room?.id,
);
}
@@ -414,4 +494,31 @@ export class RoomsSidePanelComponent {
const room = this.currentRoom();
return room?.permissions?.allowVoice !== false;
}
/**
* Get the measured latency (ms) to a voice user.
* Returns `null` when no measurement is available yet.
*/
getPeerLatency(user: User): number | null {
const latencies = this.webrtc.peerLatencies();
// Try oderId first (primary peer key), then fall back to user id
return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null;
}
/**
* Return a Tailwind `bg-*` class representing the latency quality.
* - green : < 100 ms
* - yellow : 100199 ms
* - orange : 200349 ms
* - red : >= 350 ms
* - gray : no data yet
*/
getPingColorClass(user: User): string {
const ms = this.getPeerLatency(user);
if (ms === null) return 'bg-gray-500';
if (ms < 100) return 'bg-green-500';
if (ms < 200) return 'bg-yellow-500';
if (ms < 350) return 'bg-orange-500';
return 'bg-red-500';
}
}

View File

@@ -29,8 +29,8 @@
</div>
<p class="text-sm text-muted-foreground mb-4">
Add multiple server directories to search for rooms across different networks.
The active server will be used for creating and registering new rooms.
Add multiple server directories to search for rooms across different networks. The active
server will be used for creating and registering new rooms.
</p>
<!-- Server List -->
@@ -58,7 +58,9 @@
<div class="flex items-center gap-2">
<span class="font-medium text-foreground truncate">{{ server.name }}</span>
@if (server.isActive) {
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">Active</span>
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full"
>Active</span
>
}
</div>
<p class="text-sm text-muted-foreground truncate">{{ server.url }}</p>
@@ -75,7 +77,10 @@
class="p-2 hover:bg-secondary rounded-lg transition-colors"
title="Set as active"
>
<ng-icon name="lucideCheck" class="w-4 h-4 text-muted-foreground hover:text-primary" />
<ng-icon
name="lucideCheck"
class="w-4 h-4 text-muted-foreground hover:text-primary"
/>
</button>
}
@if (!server.isDefault) {
@@ -84,7 +89,10 @@
class="p-2 hover:bg-destructive/10 rounded-lg transition-colors"
title="Remove server"
>
<ng-icon name="lucideTrash2" class="w-4 h-4 text-muted-foreground hover:text-destructive" />
<ng-icon
name="lucideTrash2"
class="w-4 h-4 text-muted-foreground hover:text-destructive"
/>
</button>
}
</div>
@@ -125,7 +133,7 @@
</div>
<!-- Connection Settings -->
<div class="bg-card border border-border rounded-lg p-6">
<div class="bg-card border border-border rounded-lg p-6 mb-6">
<div class="flex items-center gap-2 mb-4">
<ng-icon name="lucideServer" class="w-5 h-5 text-muted-foreground" />
<h2 class="text-lg font-semibold text-foreground">Connection Settings</h2>
@@ -135,7 +143,9 @@
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Auto-reconnect</p>
<p class="text-sm text-muted-foreground">Automatically reconnect when connection is lost</p>
<p class="text-sm text-muted-foreground">
Automatically reconnect when connection is lost
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -144,14 +154,18 @@
(change)="saveConnectionSettings()"
class="sr-only peer"
/>
<div class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"></div>
<div
class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"
></div>
</label>
</div>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Search all servers</p>
<p class="text-sm text-muted-foreground">Search across all configured server directories</p>
<p class="text-sm text-muted-foreground">
Search across all configured server directories
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -160,7 +174,39 @@
(change)="saveConnectionSettings()"
class="sr-only peer"
/>
<div class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"></div>
<div
class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"
></div>
</label>
</div>
</div>
</div>
<!-- Voice Settings -->
<div class="bg-card border border-border rounded-lg p-6">
<div class="flex items-center gap-2 mb-4">
<ng-icon name="lucideAudioLines" class="w-5 h-5 text-muted-foreground" />
<h2 class="text-lg font-semibold text-foreground">Voice Settings</h2>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Noise reduction</p>
<p class="text-sm text-muted-foreground">
Use RNNoise to suppress background noise from your microphone
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[(ngModel)]="noiseReduction"
(change)="saveVoiceSettings()"
class="sr-only peer"
/>
<div
class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"
></div>
</label>
</div>
</div>

View File

@@ -13,10 +13,12 @@ import {
lucideRefreshCw,
lucideGlobe,
lucideArrowLeft,
lucideAudioLines,
} from '@ng-icons/lucide';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../core/constants';
import { WebRTCService } from '../../core/services/webrtc.service';
import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../../core/constants';
@Component({
selector: 'app-settings',
@@ -33,6 +35,7 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../core/constants';
lucideRefreshCw,
lucideGlobe,
lucideArrowLeft,
lucideAudioLines,
}),
],
templateUrl: './settings.component.html',
@@ -42,6 +45,7 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../core/constants';
*/
export class SettingsComponent implements OnInit {
private serverDirectory = inject(ServerDirectoryService);
private webrtcService = inject(WebRTCService);
private router = inject(Router);
servers = this.serverDirectory.servers;
@@ -52,10 +56,12 @@ export class SettingsComponent implements OnInit {
newServerUrl = '';
autoReconnect = true;
searchAllServers = true;
noiseReduction = false;
/** Load persisted connection settings on component init. */
ngOnInit(): void {
this.loadConnectionSettings();
this.loadVoiceSettings();
}
/** Add a new signaling server after URL validation and duplicate checking. */
@@ -128,7 +134,7 @@ export class SettingsComponent implements OnInit {
JSON.stringify({
autoReconnect: this.autoReconnect,
searchAllServers: this.searchAllServers,
})
}),
);
this.serverDirectory.setSearchAllServers(this.searchAllServers);
}
@@ -137,4 +143,32 @@ export class SettingsComponent implements OnInit {
goBack(): void {
this.router.navigate(['/']);
}
/** Load voice settings (noise reduction) from localStorage. */
loadVoiceSettings(): void {
const settings = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
if (settings) {
const parsed = JSON.parse(settings);
this.noiseReduction = parsed.noiseReduction ?? false;
}
// Sync the live WebRTC state with the persisted preference
if (this.noiseReduction !== this.webrtcService.isNoiseReductionEnabled()) {
this.webrtcService.toggleNoiseReduction(this.noiseReduction);
}
}
/** Persist noise reduction preference (merged into existing voice settings) and apply immediately. */
async saveVoiceSettings(): Promise<void> {
// Merge into existing voice settings so we don't overwrite device/volume prefs
let existing: Record<string, unknown> = {};
try {
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
if (raw) existing = JSON.parse(raw);
} catch {}
localStorage.setItem(
STORAGE_KEY_VOICE_SETTINGS,
JSON.stringify({ ...existing, noiseReduction: this.noiseReduction }),
);
await this.webrtcService.toggleNoiseReduction(this.noiseReduction);
}
}

View File

@@ -1,10 +1,16 @@
<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">
<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 (click)="retryConnection()" class="ml-auto text-xs text-destructive hover:underline">Retry</button>
<span class="text-xs text-destructive">{{
connectionErrorMessage() || 'Connection error'
}}</span>
<button (click)="retryConnection()" class="ml-auto text-xs text-destructive hover:underline">
Retry
</button>
</div>
}
@@ -25,10 +31,7 @@
}
</p>
</div>
<button
(click)="toggleSettings()"
class="p-2 hover:bg-secondary rounded-lg transition-colors"
>
<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>
@@ -37,10 +40,7 @@
<div class="flex items-center justify-center gap-2">
@if (isConnected()) {
<!-- Mute Toggle -->
<button
(click)="toggleMute()"
[class]="getMuteButtonClass()"
>
<button (click)="toggleMute()" [class]="getMuteButtonClass()">
@if (isMuted()) {
<ng-icon name="lucideMicOff" class="w-5 h-5" />
} @else {
@@ -49,18 +49,12 @@
</button>
<!-- Deafen Toggle -->
<button
(click)="toggleDeafen()"
[class]="getDeafenButtonClass()"
>
<button (click)="toggleDeafen()" [class]="getDeafenButtonClass()">
<ng-icon name="lucideHeadphones" class="w-5 h-5" />
</button>
<!-- Screen Share Toggle -->
<button
(click)="toggleScreenShare()"
[class]="getScreenShareButtonClass()"
>
<button (click)="toggleScreenShare()" [class]="getScreenShareButtonClass()">
@if (isScreenSharing()) {
<ng-icon name="lucideMonitorOff" class="w-5 h-5" />
} @else {
@@ -80,8 +74,14 @@
<!-- Settings Modal -->
@if (showSettings()) {
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeSettings()">
<div class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4" (click)="$event.stopPropagation()">
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
(click)="closeSettings()"
>
<div
class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4"
(click)="$event.stopPropagation()"
>
<h2 class="text-xl font-semibold text-foreground mb-4">Voice Settings</h2>
<div class="space-y-4">
@@ -92,7 +92,10 @@
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (device of inputDevices(); track device.deviceId) {
<option [value]="device.deviceId" [selected]="device.deviceId === selectedInputDevice()">
<option
[value]="device.deviceId"
[selected]="device.deviceId === selectedInputDevice()"
>
{{ device.label || 'Microphone ' + $index }}
</option>
}
@@ -106,7 +109,10 @@
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (device of outputDevices(); track device.deviceId) {
<option [value]="device.deviceId" [selected]="device.deviceId === selectedOutputDevice()">
<option
[value]="device.deviceId"
[selected]="device.deviceId === selectedOutputDevice()"
>
{{ device.label || 'Speaker ' + $index }}
</option>
}
@@ -154,14 +160,36 @@
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">Include system audio when sharing screen</label>
<label class="block text-sm font-medium text-foreground mb-1"
>Include system audio when sharing screen</label
>
<input
type="checkbox"
[checked]="includeSystemAudio()"
(change)="onIncludeSystemAudioChange($event)"
class="accent-primary"
/>
<p class="text-xs text-muted-foreground">Off by default; viewers will still hear your mic.</p>
<p class="text-xs text-muted-foreground">
Off by default; viewers will still hear your mic.
</p>
</div>
<div class="flex items-center justify-between">
<div>
<label class="block text-sm font-medium text-foreground">Noise reduction</label>
<p class="text-xs text-muted-foreground">Suppress background noise using RNNoise</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[checked]="noiseReduction()"
(change)="onNoiseReductionChange($event)"
class="sr-only peer"
/>
<div
class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"
></div>
</label>
</div>
<div>

View File

@@ -86,6 +86,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
audioBitrate = signal(96);
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
includeSystemAudio = signal(false);
noiseReduction = signal(false);
private voiceConnectedSubscription: Subscription | null = null;
@@ -264,7 +265,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
},
});
this.webrtcService.setLocalStream(stream);
await this.webrtcService.setLocalStream(stream);
// Track local mic for voice-activity visualisation
const userId = this.currentUser()?.id;
@@ -496,6 +497,13 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
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 {
try {
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
@@ -508,6 +516,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
audioBitrate?: number;
latencyProfile?: 'low' | 'balanced' | 'high';
includeSystemAudio?: boolean;
noiseReduction?: boolean;
};
if (settings.inputDevice) this.selectedInputDevice.set(settings.inputDevice);
if (settings.outputDevice) this.selectedOutputDevice.set(settings.outputDevice);
@@ -517,6 +526,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
if (settings.latencyProfile) this.latencyProfile.set(settings.latencyProfile);
if (typeof settings.includeSystemAudio === 'boolean')
this.includeSystemAudio.set(settings.includeSystemAudio);
if (typeof settings.noiseReduction === 'boolean')
this.noiseReduction.set(settings.noiseReduction);
} catch {}
}
@@ -530,6 +541,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
audioBitrate: this.audioBitrate(),
latencyProfile: this.latencyProfile(),
includeSystemAudio: this.includeSystemAudio(),
noiseReduction: this.noiseReduction(),
};
localStorage.setItem(STORAGE_KEY_VOICE_SETTINGS, JSON.stringify(voiceSettings));
} catch {}
@@ -541,6 +553,9 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
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 {}
}