Noise reduction beta
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 : 100–199 ms
|
||||
* - orange : 200–349 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user