feat: Add TURN server support
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 5m35s
Queue Release Build / build-linux (push) Successful in 24m45s
Queue Release Build / build-windows (push) Successful in 13m52s
Queue Release Build / finalize (push) Successful in 23s

This commit is contained in:
2026-04-18 21:27:04 +02:00
parent 167c45ba8d
commit 44588e8789
60 changed files with 2404 additions and 365 deletions

42
toju-app/README.md Normal file
View File

@@ -0,0 +1,42 @@
# Product Client
Angular 21 renderer for MetoYou / Toju. This package is managed from the repository root, so the main build, test, lint, and Electron integration commands are run there rather than from a local `package.json`.
## Commands
- `npm run start` starts the Angular dev server.
- `npm run build` builds the client to `dist/client`.
- `npm run watch` runs the Angular build in watch mode.
- `npm run test` runs the product-client Vitest suite.
- `npm run lint` runs ESLint across the repo.
- `npm run format` formats Angular HTML templates.
- `npm run sort:props` sorts Angular template properties.
- `npm run electron:dev` or `npm run dev` runs the client with Electron.
## Structure
| Path | Description |
| --- | --- |
| `src/app/domains/` | Bounded contexts and public domain entry points |
| `src/app/infrastructure/` | Shared technical runtime such as persistence and realtime |
| `src/app/shared-kernel/` | Cross-domain contracts and shared models |
| `src/app/features/` | App-level composition and transitional feature shells |
| `src/app/core/` | Platform adapters, compatibility entry points, and cross-domain technical helpers |
| `src/app/shared/` | Shared UI primitives and utilities |
| `src/app/store/` | NgRx reducers, effects, selectors, and actions |
| `public/` | Static assets copied into the Angular build |
## Key Docs
- [src/app/domains/README.md](src/app/domains/README.md)
- [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md)
- [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md)
- [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md)
- [../docs/architecture.md](../docs/architecture.md)
- [AGENTS.md](AGENTS.md)
## Notes
- `angular.json` defines build, serve, and lint targets for the product client.
- Product-client tests currently run through the root Vitest setup instead of an Angular `test` architect target.
- If the renderer-to-desktop contract changes, update the Angular bridge, Electron preload API, and IPC handlers together.

View File

@@ -97,7 +97,7 @@
{
"type": "initial",
"maximumWarning": "2.2MB",
"maximumError": "2.32MB"
"maximumError": "2.35MB"
},
{
"type": "anyComponentStyle",

View File

@@ -28,7 +28,7 @@
@if (themeStudioFullscreenComponent()) {
<ng-container *ngComponentOutlet="themeStudioFullscreenComponent()" />
} @else {
<div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">Loading Theme Studio</div>
<div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">Loading Theme Studio...</div>
}
</div>
} @else { @if (showDesktopUpdateNotice()) {

View File

@@ -5,6 +5,7 @@ export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
export const STORAGE_KEY_ICE_SERVERS = 'metoyou_ice_servers';
export const STORAGE_KEY_THEME_ACTIVE = 'metoyou_theme_active';
export const STORAGE_KEY_THEME_DRAFT = 'metoyou_theme_draft';
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';

View File

@@ -1508,7 +1508,7 @@ class DebugNetworkSnapshotBuilder {
if (value.length <= 12)
return value;
return `${value.slice(0, 6)}${value.slice(-4)}`;
return `${value.slice(0, 6)}...${value.slice(-4)}`;
}
private getEntryPayloadRecord(payload: unknown): Record<string, unknown> | null {

View File

@@ -190,7 +190,7 @@
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
>
{{ att.requestError || 'Waiting for image source' }}
{{ att.requestError || 'Waiting for image source...' }}
</div>
</div>
</div>

View File

@@ -419,8 +419,8 @@ export class ChatMessageItemComponent {
}
return this.isVideoAttachment(attachment)
? 'Waiting for video source'
: 'Waiting for audio source';
? 'Waiting for video source...'
: 'Waiting for audio source...';
}
getMediaAttachmentActionLabel(attachment: Attachment): string {
@@ -502,8 +502,8 @@ export class ChatMessageItemComponent {
? 'Large video. Accept the download to watch it in chat.'
: 'Large audio file. Accept the download to play it in chat.'
: isVideo
? 'Waiting for video source'
: 'Waiting for audio source',
? 'Waiting for video source...'
: 'Waiting for audio source...',
progressPercent: attachment.size > 0
? ((attachment.receivedBytes || 0) * 100) / attachment.size
: 0

View File

@@ -7,7 +7,7 @@
@if (syncing() && !loading()) {
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
<div class="h-3 w-3 animate-spin rounded-full border-b-2 border-primary"></div>
<span>Syncing messages</span>
<span>Syncing messages...</span>
</div>
}

View File

@@ -62,7 +62,7 @@
@if (loading() && results().length === 0) {
<div class="flex h-full min-h-56 flex-col items-center justify-center gap-3 text-muted-foreground">
<span class="h-6 w-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
<p class="text-sm">Loading GIFs from KLIPY</p>
<p class="text-sm">Loading GIFs from KLIPY...</p>
</div>
} @else if (results().length === 0) {
<div
@@ -125,7 +125,7 @@
[disabled]="loading()"
class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{{ loading() ? 'Loading' : 'Load more' }}
{{ loading() ? 'Loading...' : 'Load more' }}
</button>
}
</div>

View File

@@ -151,7 +151,7 @@ function formatMessagePreview(senderName: string, content: string): string {
}
const preview = normalisedContent.length > MESSAGE_PREVIEW_LIMIT
? `${normalisedContent.slice(0, MESSAGE_PREVIEW_LIMIT - 1)}`
? `${normalisedContent.slice(0, MESSAGE_PREVIEW_LIMIT - 1)}...`
: normalisedContent;
return `${senderName}: ${preview}`;

View File

@@ -27,7 +27,7 @@ export class InviteComponent implements OnInit {
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
readonly invite = signal<ServerInviteInfo | null>(null);
readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading');
readonly message = signal('Loading invite');
readonly message = signal('Loading invite...');
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
@@ -121,7 +121,7 @@ export class InviteComponent implements OnInit {
this.invite.set(invite);
this.status.set('joining');
this.message.set(`Joining ${invite.server.name}`);
this.message.set(`Joining ${invite.server.name}...`);
const currentUser = await this.hydrateCurrentUser();
const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({
@@ -163,7 +163,7 @@ export class InviteComponent implements OnInit {
private async redirectToLogin(): Promise<void> {
this.status.set('redirecting');
this.message.set('Redirecting to login');
this.message.set('Redirecting to login...');
await this.router.navigate(['/login'], {
queryParams: {

View File

@@ -0,0 +1,139 @@
import {
Injectable,
inject,
computed,
type Signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';
import { toSignal } from '@angular/core/rxjs-interop';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import type { User } from '../../../../shared-kernel';
/**
* Connectivity health status for a single peer in voice.
*/
export interface PeerConnectivityHealth {
peerId: string;
/** Number of voice peers this peer can send/receive audio to/from. */
connectedPeerCount: number;
/** Total peers expected in voice. */
totalVoicePeers: number;
/** true when this peer has the fewest connections -> warning target. */
hasDesync: boolean;
}
/**
* Tracks per-peer voice connectivity health by comparing the number
* of connected audio streams each peer has. Peers with fewest
* bidirectional audio connections are flagged.
*
* Uses peer latency data as proxy for healthy bidirectional connection.
*/
@Injectable({ providedIn: 'root' })
export class VoiceConnectivityHealthService {
readonly currentUser: Signal<User | null | undefined>;
readonly onlineUsers: Signal<User[]>;
readonly desyncPeerIds: Signal<ReadonlySet<string>>;
readonly localUserHasDesync: Signal<boolean>;
private readonly webrtc = inject(RealtimeSessionFacade);
constructor() {
const store = inject(Store);
this.currentUser = toSignal(store.select(selectCurrentUser));
this.onlineUsers = toSignal(store.select(selectOnlineUsers), { initialValue: [] });
/**
* Map of peerId -> true for peers that have connectivity issues.
* A peer is flagged when it has fewer healthy connections than the
* majority of users in the same voice channel.
*/
this.desyncPeerIds = computed<ReadonlySet<string>>(() => {
const me = this.currentUser();
const myVoice = me?.voiceState;
if (!myVoice?.isConnected || !myVoice.roomId || !myVoice.serverId) {
return new Set<string>();
}
// Find all users in same voice room
const voiceUsers = this.onlineUsers().filter(
(user) =>
user.voiceState?.isConnected
&& user.voiceState.roomId === myVoice.roomId
&& user.voiceState.serverId === myVoice.serverId
);
if (voiceUsers.length < 2) {
return new Set<string>();
}
// Use peer latencies as proxy. A peer we can ping has a working
// data-channel (= working RTCPeerConnection). Peers without latency
// measurements are considered unreachable.
const connectedPeers = this.webrtc.connectedPeers();
const connectedSet = new Set(connectedPeers);
const myKey = me?.oderId || me?.id;
if (!myKey) {
return new Set<string>();
}
// Count how many voice peers each voice user is connected to (from
// the local perspective). We can only see our own connections - but
// if WE can't reach peer X while we CAN reach peers Y and Z, peer X
// is the one with issues.
const unreachableFromUs = new Set<string>();
for (const user of voiceUsers) {
const key = user.oderId || user.id;
if (key === myKey) {
continue;
}
const hasConnection = connectedSet.has(key)
|| connectedSet.has(user.id)
|| connectedSet.has(user.oderId ?? '');
if (!hasConnection) {
unreachableFromUs.add(key);
}
}
// If we can reach everyone, no desync
if (unreachableFromUs.size === 0) {
return new Set<string>();
}
// If we can't reach ANYONE, the problem is likely on our end
const reachableCount = voiceUsers.length - 1 - unreachableFromUs.size;
if (reachableCount === 0 && voiceUsers.length > 2) {
// Everyone unreachable from us -> WE are the problem
return new Set([myKey]);
}
return unreachableFromUs;
});
/**
* Whether the LOCAL user is the one with connectivity issues.
*/
this.localUserHasDesync = computed(() => {
const me = this.currentUser();
const myKey = me?.oderId || me?.id;
return !!myKey && this.desyncPeerIds().has(myKey);
});
}
/**
* Check if a specific peer has a desync warning.
*/
hasPeerDesync(peerKey: string): boolean {
return this.desyncPeerIds().has(peerKey);
}
}

View File

@@ -209,7 +209,7 @@ export class VoicePlaybackService {
* ↓
* muted <audio> element (Chrome workaround - primes the stream)
* ↓
* MediaStreamSource GainNode MediaStreamDestination output <audio>
* MediaStreamSource -> GainNode -> MediaStreamDestination -> output <audio>
*/
private createPipeline(peerId: string, stream: MediaStream): void {
// Chromium/Electron needs a muted <audio> element before Web Audio can read the stream.

View File

@@ -1,4 +1,5 @@
export * from './application/facades/voice-connection.facade';
export * from './application/services/voice-activity.service';
export * from './application/services/voice-playback.service';
export * from './application/services/voice-connectivity-health.service';
export * from './domain/models/voice-connection.model';

View File

@@ -189,6 +189,14 @@
[ringClass]="getVoiceUserRingClass(u)"
/>
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
<!-- Connectivity warning -->
@if (hasConnectivityIssue(u)) {
<ng-icon
name="lucideAlertTriangle"
class="w-3.5 h-3.5 text-amber-500 shrink-0"
title="Connection issue - this user may not hear all participants. Consider adding a TURN server in Settings -> Network."
/>
}
<!-- Ping latency indicator -->
@if (u.id !== currentUser()?.id) {
<span
@@ -399,6 +407,15 @@
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
@if (panelMode() === 'channels' && showVoiceControls() && voiceEnabled()) {
@if (localUserHasDesync()) {
<div class="mx-2 mb-1 flex items-center gap-2 rounded-md bg-amber-500/15 px-3 py-2 text-xs text-amber-400">
<ng-icon
name="lucideAlertTriangle"
class="w-4 h-4 shrink-0"
/>
<span>You may have connectivity issues. Adding a TURN server in Settings -> Network may help.</span>
</div>
}
<div
class="border-t border-border px-2 py-3"
[class.invisible]="showFloatingControls()"

View File

@@ -15,6 +15,7 @@ import {
lucideMic,
lucideMicOff,
lucideChevronLeft,
lucideAlertTriangle,
lucideMonitor,
lucideVideo,
lucideHash,
@@ -35,7 +36,11 @@ import { MessagesActions } from '../../../store/messages/messages.actions';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ScreenShareFacade } from '../../../domains/screen-share';
import { NotificationsFacade } from '../../../domains/notifications';
import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection';
import {
VoiceActivityService,
VoiceConnectionFacade,
VoiceConnectivityHealthService
} from '../../../domains/voice-connection';
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
import { VoicePlaybackService } from '../../../domains/voice-connection';
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
@@ -83,6 +88,7 @@ type PanelMode = 'channels' | 'users';
lucideMic,
lucideMicOff,
lucideChevronLeft,
lucideAlertTriangle,
lucideMonitor,
lucideVideo,
lucideHash,
@@ -104,6 +110,7 @@ export class RoomsSidePanelComponent {
private voicePlayback = inject(VoicePlaybackService);
private profileCard = inject(ProfileCardService);
private readonly voiceActivity = inject(VoiceActivityService);
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
readonly panelMode = input<PanelMode>('channels');
readonly showVoiceControls = input(true);
@@ -115,6 +122,7 @@ export class RoomsSidePanelComponent {
activeChannelId = this.store.selectSignal(selectActiveChannelId);
textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels);
localUserHasDesync = this.voiceConnectivity.localUserHasDesync;
roomMembers = computed(() => this.currentRoom()?.members ?? []);
roomMemberIdentifiers = computed(() => {
const identifiers = new Set<string>();
@@ -248,6 +256,10 @@ export class RoomsSidePanelComponent {
);
}
hasConnectivityIssue(user: User): boolean {
return this.voiceConnectivity.hasPeerDesync(user.oderId || user.id);
}
canManageChannels(): boolean {
const room = this.currentRoom();
const user = this.currentUser();

View File

@@ -0,0 +1,161 @@
<section data-testid="ice-server-settings">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<ng-icon
name="lucideShield"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">ICE Servers (STUN / TURN)</h4>
</div>
<button
type="button"
data-testid="ice-restore-defaults"
(click)="restoreDefaults()"
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
>
<ng-icon
name="lucideRotateCcw"
class="w-3.5 h-3.5"
/>
Restore Defaults
</button>
</div>
<p class="text-xs text-muted-foreground mb-3">
ICE servers are used for NAT traversal. STUN discovers your public address; TURN relays traffic when direct connections fail. Higher entries have
priority.
</p>
<!-- ICE Server List -->
<div
class="space-y-2 mb-3"
data-testid="ice-server-list"
>
@for (entry of entries(); track trackEntry($index, entry); let i = $index) {
<div
class="flex items-center gap-3 p-2.5 rounded-lg border transition-colors"
[class.border-blue-500/40]="entry.type === 'turn'"
[class.bg-blue-500/5]="entry.type === 'turn'"
[class.border-border]="entry.type === 'stun'"
[class.bg-secondary/30]="entry.type === 'stun'"
[attr.data-testid]="'ice-entry-' + i"
>
<span
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded"
[class.bg-muted]="entry.type === 'stun'"
[class.text-muted-foreground]="entry.type === 'stun'"
[class.bg-blue-500/15]="entry.type === 'turn'"
[class.text-blue-400]="entry.type === 'turn'"
>
{{ entry.type }}
</span>
<div class="flex-1 min-w-0">
<p class="text-sm text-foreground truncate">{{ entry.urls }}</p>
@if (entry.type === 'turn' && entry.username) {
<p class="text-[10px] text-muted-foreground truncate">User: {{ entry.username }}</p>
}
</div>
<div class="flex items-center gap-0.5 flex-shrink-0">
<button
type="button"
(click)="moveUp(i)"
[disabled]="i === 0"
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-secondary disabled:opacity-30"
title="Move up (higher priority)"
>
<ng-icon
name="lucideArrowUp"
class="w-3.5 h-3.5 text-muted-foreground"
/>
</button>
<button
type="button"
(click)="moveDown(i)"
[disabled]="i === entries().length - 1"
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-secondary disabled:opacity-30"
title="Move down (lower priority)"
>
<ng-icon
name="lucideArrowDown"
class="w-3.5 h-3.5 text-muted-foreground"
/>
</button>
<button
type="button"
(click)="removeEntry(entry.id)"
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-destructive/10"
title="Remove"
>
<ng-icon
name="lucideTrash2"
class="w-3.5 h-3.5 text-muted-foreground hover:text-destructive"
/>
</button>
</div>
</div>
}
@if (entries().length === 0) {
<p class="text-xs text-muted-foreground italic py-2">No ICE servers configured. P2P connections may fail across networks.</p>
}
</div>
<!-- Add New ICE Server -->
<div class="border-t border-border pt-3">
<h4 class="text-xs font-medium text-foreground mb-2">Add ICE Server</h4>
<div class="space-y-1.5">
<div class="flex gap-2">
<select
[(ngModel)]="newType"
data-testid="ice-type-select"
class="px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="stun">STUN</option>
<option value="turn">TURN</option>
</select>
<input
type="text"
[(ngModel)]="newUrl"
data-testid="ice-url-input"
[placeholder]="newType === 'stun' ? 'stun:stun.example.com:19302' : 'turn:turn.example.com:3478'"
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@if (newType === 'turn') {
<div class="flex gap-2">
<input
type="text"
[(ngModel)]="newUsername"
data-testid="ice-username-input"
placeholder="Username"
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="password"
[(ngModel)]="newCredential"
data-testid="ice-credential-input"
placeholder="Credential"
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
}
<div class="flex justify-end">
<button
type="button"
data-testid="ice-add-button"
(click)="addEntry()"
[disabled]="!newUrl"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
>
<ng-icon
name="lucidePlus"
class="w-3.5 h-3.5"
/>
Add Server
</button>
</div>
</div>
@if (addError()) {
<p class="text-xs text-destructive mt-1.5">{{ addError() }}</p>
}
</div>
</section>

View File

@@ -0,0 +1,121 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideShield,
lucidePlus,
lucideTrash2,
lucideArrowUp,
lucideArrowDown,
lucideRotateCcw
} from '@ng-icons/lucide';
import { IceServerSettingsService, IceServerEntry } from '../../../../infrastructure/realtime/ice-server-settings.service';
@Component({
selector: 'app-ice-server-settings',
standalone: true,
host: {
style: 'display: block;'
},
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideShield,
lucidePlus,
lucideTrash2,
lucideArrowUp,
lucideArrowDown,
lucideRotateCcw
})
],
templateUrl: './ice-server-settings.component.html'
})
export class IceServerSettingsComponent {
private iceSettings = inject(IceServerSettingsService);
entries = this.iceSettings.entries;
addError = signal<string | null>(null);
newType: 'stun' | 'turn' = 'stun';
newUrl = '';
newUsername = '';
newCredential = '';
addEntry(): void {
this.addError.set(null);
const url = this.newUrl.trim();
if (!url) {
this.addError.set('URL is required');
return;
}
const prefix = this.newType === 'stun' ? 'stun:' : 'turn';
if (!url.startsWith(prefix) && !url.startsWith('turns:')) {
this.addError.set(`URL must start with ${this.newType === 'stun' ? 'stun:' : 'turn: or turns:'}`);
return;
}
if (this.newType === 'turn' && !this.newUsername.trim()) {
this.addError.set('Username is required for TURN servers');
return;
}
if (this.newType === 'turn' && !this.newCredential.trim()) {
this.addError.set('Credential is required for TURN servers');
return;
}
if (this.entries().some((entry) => entry.urls === url)) {
this.addError.set('This URL already exists');
return;
}
this.iceSettings.addEntry({
type: this.newType,
urls: url,
...(this.newType === 'turn'
? { username: this.newUsername.trim(), credential: this.newCredential.trim() }
: {})
});
this.newUrl = '';
this.newUsername = '';
this.newCredential = '';
}
removeEntry(id: string): void {
this.iceSettings.removeEntry(id);
}
moveUp(index: number): void {
if (index > 0)
this.iceSettings.moveEntry(index, index - 1);
}
moveDown(index: number): void {
if (index < this.entries().length - 1)
this.iceSettings.moveEntry(index, index + 1);
}
restoreDefaults(): void {
this.iceSettings.restoreDefaults();
}
trackEntry(_index: number, entry: IceServerEntry): string {
return entry.id;
}
}

View File

@@ -199,4 +199,7 @@
</div>
</div>
</section>
<!-- ICE Server Settings (STUN / TURN) -->
<app-ice-server-settings />
</div>

View File

@@ -20,6 +20,7 @@ import {
import { ServerDirectoryFacade } from '../../../../domains/server-directory';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-settings.component';
@Component({
selector: 'app-network-settings',
@@ -27,7 +28,8 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
imports: [
CommonModule,
FormsModule,
NgIcon
NgIcon,
IceServerSettingsComponent
],
viewProviders: [
provideIcons({

View File

@@ -70,7 +70,7 @@
[value]="selectedServerId() || ''"
(change)="onServerSelect($event)"
>
<option value="">Select a server</option>
<option value="">Select a server...</option>
@for (room of manageableRooms(); track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}

View File

@@ -73,7 +73,7 @@
[value]="state().preferredVersion || ''"
(change)="onVersionChange($event)"
>
<option value="">Choose a release</option>
<option value="">Choose a release...</option>
@for (version of state().availableVersions; track version) {
<option [value]="version">{{ version }}</option>
}

View File

@@ -11,7 +11,8 @@
class="context-menu-item"
[disabled]="!params()!.editFlags.canCut"
[class.opacity-40]="!params()!.editFlags.canCut"
(click)="execCommand('cut')"
(pointerdown)="onActionPointerDown($event, 'cut')"
(click)="onActionClick($event, 'cut')"
>
Cut
</button>
@@ -20,7 +21,8 @@
class="context-menu-item"
[disabled]="!params()!.editFlags.canCopy"
[class.opacity-40]="!params()!.editFlags.canCopy"
(click)="execCommand('copy')"
(pointerdown)="onActionPointerDown($event, 'copy')"
(click)="onActionClick($event, 'copy')"
>
Copy
</button>
@@ -29,7 +31,8 @@
class="context-menu-item"
[disabled]="!params()!.editFlags.canPaste"
[class.opacity-40]="!params()!.editFlags.canPaste"
(click)="execCommand('paste')"
(pointerdown)="onActionPointerDown($event, 'paste')"
(click)="onActionClick($event, 'paste')"
>
Paste
</button>
@@ -39,7 +42,8 @@
class="context-menu-item"
[disabled]="!params()!.editFlags.canSelectAll"
[class.opacity-40]="!params()!.editFlags.canSelectAll"
(click)="execCommand('selectAll')"
(pointerdown)="onActionPointerDown($event, 'selectAll')"
(click)="onActionClick($event, 'selectAll')"
>
Select All
</button>
@@ -47,7 +51,8 @@
<button
type="button"
class="context-menu-item"
(click)="execCommand('copy')"
(pointerdown)="onActionPointerDown($event, 'copy')"
(click)="onActionClick($event, 'copy')"
>
Copy
</button>
@@ -60,7 +65,8 @@
<button
type="button"
class="context-menu-item"
(click)="copyLink()"
(pointerdown)="onActionPointerDown($event, 'copyLink')"
(click)="onActionClick($event, 'copyLink')"
>
Copy Link
</button>
@@ -73,7 +79,8 @@
<button
type="button"
class="context-menu-item"
(click)="copyImage()"
(pointerdown)="onActionPointerDown($event, 'copyImage')"
(click)="onActionClick($event, 'copyImage')"
>
Copy Image
</button>

View File

@@ -2,13 +2,48 @@ import {
Component,
OnInit,
OnDestroy,
HostListener,
inject,
signal
} from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { ContextMenuComponent } from '../../../shared';
import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models';
type ContextMenuCommand = 'cut' | 'copy' | 'paste' | 'selectAll';
type ContextMenuAction = ContextMenuCommand | 'copyLink' | 'copyImage';
type TextControlElement = HTMLInputElement | HTMLTextAreaElement;
type ContextMenuTarget = TextControlElement | HTMLElement;
interface ContextMenuSelectionSnapshot {
range: Range | null;
selectedText: string;
selectionDirection: 'forward' | 'backward' | 'none' | null;
selectionEnd: number | null;
selectionStart: number | null;
target: ContextMenuTarget | null;
}
const NON_TEXT_INPUT_TYPES = new Set([
'button',
'checkbox',
'color',
'date',
'datetime-local',
'file',
'hidden',
'image',
'month',
'number',
'radio',
'range',
'reset',
'submit',
'time',
'week'
]);
@Component({
selector: 'app-native-context-menu',
standalone: true,
@@ -18,8 +53,29 @@ import type { ContextMenuParams } from '../../../core/platform/electron/electron
export class NativeContextMenuComponent implements OnInit, OnDestroy {
params = signal<ContextMenuParams | null>(null);
private readonly document = inject(DOCUMENT);
private readonly electronBridge = inject(ElectronBridgeService);
private cleanup: (() => void) | null = null;
private selectionSnapshot: ContextMenuSelectionSnapshot | null = null;
@HostListener('document:contextmenu', ['$event'])
onDocumentContextMenu(event: MouseEvent): void {
this.captureSelectionSnapshot(event);
if (this.electronBridge.isAvailable) {
return;
}
const params = this.buildBrowserContextMenuParams(event);
if (!params) {
this.close();
return;
}
event.preventDefault();
this.params.set(params);
}
ngOnInit(): void {
const api = this.electronBridge.getApi();
@@ -34,7 +90,12 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|| !!incoming.linkURL
|| (incoming.mediaType === 'image' && !!incoming.srcURL);
this.params.set(hasContent ? incoming : null);
if (!hasContent) {
this.close();
return;
}
this.params.set(incoming);
});
}
@@ -45,36 +106,514 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
close(): void {
this.params.set(null);
this.selectionSnapshot = null;
}
execCommand(command: string): void {
onActionPointerDown(event: PointerEvent, action: ContextMenuAction): void {
if (event.button !== 0) {
return;
}
event.preventDefault();
void this.runAction(action);
}
onActionClick(event: MouseEvent, action: ContextMenuAction): void {
if (event.detail > 0) {
return;
}
event.preventDefault();
void this.runAction(action);
}
private async runAction(action: ContextMenuAction): Promise<void> {
try {
switch (action) {
case 'copyLink':
await this.copyLink();
break;
case 'copyImage':
await this.copyImage();
break;
default:
await this.execCommand(action);
break;
}
} finally {
this.close();
}
}
private async execCommand(command: ContextMenuCommand): Promise<void> {
let handled = false;
switch (command) {
case 'copy':
handled = await this.copySelection();
break;
case 'cut':
handled = await this.cutSelection();
break;
case 'paste':
handled = await this.pasteSelection();
break;
case 'selectAll':
handled = this.selectAllSelection();
break;
}
if (handled) {
return;
}
const api = this.electronBridge.getApi();
if (api?.contextMenuCommand) {
api.contextMenuCommand(command);
this.restoreSelectionSnapshot();
await api.contextMenuCommand(command);
}
this.close();
}
copyLink(): void {
private async copyLink(): Promise<void> {
const url = this.params()?.linkURL;
if (url) {
navigator.clipboard.writeText(url).catch(() => {});
await this.writeTextToClipboard(url);
}
this.close();
}
copyImage(): void {
private async copyImage(): Promise<void> {
const srcURL = this.params()?.srcURL;
const api = this.electronBridge.getApi();
if (srcURL && api?.copyImageToClipboard) {
api.copyImageToClipboard(srcURL).catch(() => {});
if (!srcURL) {
return;
}
this.close();
if (api?.copyImageToClipboard) {
const copied = await api.copyImageToClipboard(srcURL).catch(() => false);
if (copied) {
return;
}
}
await this.copyImageToBrowserClipboard(srcURL);
}
private captureSelectionSnapshot(event: MouseEvent): void {
const target = this.getTargetElement(event.target);
const textControl = this.resolveTextControlTarget(target);
if (textControl) {
this.selectionSnapshot = this.createTextControlSnapshot(textControl);
return;
}
const selection = this.document.getSelection();
this.selectionSnapshot = {
range: selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null,
selectedText: selection?.toString() ?? '',
selectionDirection: null,
selectionEnd: null,
selectionStart: null,
target: this.resolveContentEditableTarget(target) ?? (target instanceof HTMLElement ? target : null)
};
}
private createTextControlSnapshot(target: TextControlElement): ContextMenuSelectionSnapshot {
return {
range: null,
selectedText: this.getTextControlSelection(target),
selectionDirection: target.selectionDirection,
selectionEnd: target.selectionEnd,
selectionStart: target.selectionStart,
target
};
}
private buildBrowserContextMenuParams(event: MouseEvent): ContextMenuParams | null {
const target = this.getTargetElement(event.target);
const editableTarget = this.resolveEditableTarget(target);
const selectionText = this.selectionSnapshot?.selectedText ?? '';
const linkURL = this.resolveLinkUrl(target);
const srcURL = this.resolveImageUrl(target);
const isEditable = !!editableTarget && !this.isDisabledTarget(editableTarget);
if (!isEditable && !selectionText && !linkURL && !srcURL) {
return null;
}
return {
posX: event.clientX,
posY: event.clientY,
isEditable,
selectionText,
linkURL,
mediaType: srcURL ? 'image' : '',
srcURL,
editFlags: {
canCut: !!selectionText && !!editableTarget && this.canWriteToTarget(editableTarget),
canCopy: !!selectionText,
canPaste: !!editableTarget && this.canWriteToTarget(editableTarget) && this.canReadClipboard(),
canSelectAll: !!editableTarget && !this.isDisabledTarget(editableTarget)
}
};
}
private restoreSelectionSnapshot(): ContextMenuTarget | null {
const snapshot = this.selectionSnapshot;
if (!snapshot?.target || !snapshot.target.isConnected) {
return null;
}
if (this.isTextControl(snapshot.target)) {
snapshot.target.focus({ preventScroll: true });
if (snapshot.selectionStart !== null && snapshot.selectionEnd !== null) {
snapshot.target.setSelectionRange(
snapshot.selectionStart,
snapshot.selectionEnd,
snapshot.selectionDirection ?? undefined
);
}
return snapshot.target;
}
if (this.isContentEditableTarget(snapshot.target)) {
snapshot.target.focus({ preventScroll: true });
}
const selection = this.document.getSelection();
if (selection && snapshot.range) {
try {
selection.removeAllRanges();
selection.addRange(snapshot.range);
} catch {
return snapshot.target;
}
}
return snapshot.target;
}
private async copySelection(): Promise<boolean> {
const text = this.selectionSnapshot?.selectedText ?? '';
if (!text) {
return false;
}
return await this.writeTextToClipboard(text);
}
private async cutSelection(): Promise<boolean> {
const target = this.restoreSelectionSnapshot();
if (!target || !this.canWriteToTarget(target)) {
return false;
}
const text = this.selectionSnapshot?.selectedText ?? '';
if (!text || !(await this.writeTextToClipboard(text))) {
return false;
}
if (this.isTextControl(target)) {
const selectionStart = target.selectionStart ?? this.selectionSnapshot?.selectionStart ?? 0;
const selectionEnd = target.selectionEnd ?? this.selectionSnapshot?.selectionEnd ?? selectionStart;
target.setRangeText('', selectionStart, selectionEnd, 'start');
this.dispatchInputEvent(target);
this.selectionSnapshot = this.createTextControlSnapshot(target);
return true;
}
const selection = this.document.getSelection();
if (!selection?.rangeCount) {
return false;
}
const range = selection.getRangeAt(0);
range.deleteContents();
selection.removeAllRanges();
selection.addRange(range);
this.dispatchInputEvent(target);
this.selectionSnapshot = {
range: range.cloneRange(),
selectedText: '',
selectionDirection: null,
selectionEnd: null,
selectionStart: null,
target
};
return true;
}
private async pasteSelection(): Promise<boolean> {
const target = this.restoreSelectionSnapshot();
if (!target || !this.canWriteToTarget(target)) {
return false;
}
const text = await this.readClipboardText();
if (text === null) {
return false;
}
if (this.isTextControl(target)) {
const selectionStart = target.selectionStart ?? target.value.length;
const selectionEnd = target.selectionEnd ?? selectionStart;
target.setRangeText(text, selectionStart, selectionEnd, 'end');
this.dispatchInputEvent(target);
this.selectionSnapshot = this.createTextControlSnapshot(target);
return true;
}
const selection = this.document.getSelection();
if (!selection) {
return false;
}
if (!selection.rangeCount) {
const range = this.document.createRange();
range.selectNodeContents(target);
range.collapse(false);
selection.addRange(range);
}
const range = selection.getRangeAt(0);
const textNode = this.document.createTextNode(text);
range.deleteContents();
range.insertNode(textNode);
range.setStartAfter(textNode);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
this.dispatchInputEvent(target);
this.selectionSnapshot = {
range: range.cloneRange(),
selectedText: '',
selectionDirection: null,
selectionEnd: null,
selectionStart: null,
target
};
return true;
}
private selectAllSelection(): boolean {
const target = this.selectionSnapshot?.target;
if (!target || !target.isConnected || this.isDisabledTarget(target)) {
return false;
}
if (this.isTextControl(target)) {
target.focus({ preventScroll: true });
target.select();
this.selectionSnapshot = this.createTextControlSnapshot(target);
return true;
}
if (!this.isContentEditableTarget(target)) {
return false;
}
const selection = this.document.getSelection();
if (!selection) {
return false;
}
const range = this.document.createRange();
target.focus({ preventScroll: true });
range.selectNodeContents(target);
selection.removeAllRanges();
selection.addRange(range);
this.selectionSnapshot = {
range: range.cloneRange(),
selectedText: selection.toString(),
selectionDirection: null,
selectionEnd: null,
selectionStart: null,
target
};
return true;
}
private async writeTextToClipboard(value: string): Promise<boolean> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return true;
} catch {}
}
const body = this.document.body;
if (!body) {
return false;
}
const textarea = this.document.createElement('textarea');
textarea.value = value;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
body.appendChild(textarea);
textarea.select();
try {
return this.document.execCommand('copy');
} catch {
return false;
} finally {
body.removeChild(textarea);
}
}
private async readClipboardText(): Promise<string | null> {
if (navigator.clipboard?.readText) {
try {
return await navigator.clipboard.readText();
} catch {}
}
return null;
}
private async copyImageToBrowserClipboard(srcURL: string): Promise<boolean> {
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
return false;
}
try {
const response = await fetch(srcURL);
if (!response.ok) {
return false;
}
const blob = await response.blob();
if (!blob.type.startsWith('image/')) {
return false;
}
await navigator.clipboard.write([
new ClipboardItem({
[blob.type || 'image/png']: blob
})
]);
return true;
} catch {
return false;
}
}
private dispatchInputEvent(target: ContextMenuTarget): void {
target.dispatchEvent(new Event('input', { bubbles: true }));
}
private getTextControlSelection(target: TextControlElement): string {
const selectionStart = target.selectionStart ?? 0;
const selectionEnd = target.selectionEnd ?? selectionStart;
return target.value.slice(selectionStart, selectionEnd);
}
private getTargetElement(target: EventTarget | null): Element | null {
if (target instanceof Element) {
return target;
}
return target instanceof Node ? target.parentElement : null;
}
private resolveEditableTarget(target: Element | null): ContextMenuTarget | null {
return this.resolveTextControlTarget(target) ?? this.resolveContentEditableTarget(target);
}
private resolveTextControlTarget(target: Element | null): TextControlElement | null {
const textControl = target?.closest('input, textarea');
if (textControl instanceof HTMLTextAreaElement) {
return textControl;
}
if (textControl instanceof HTMLInputElement && !NON_TEXT_INPUT_TYPES.has(textControl.type)) {
return textControl;
}
return null;
}
private resolveContentEditableTarget(target: Element | null): HTMLElement | null {
const editable = target?.closest('[contenteditable]:not([contenteditable="false"])');
return editable instanceof HTMLElement && editable.isContentEditable ? editable : null;
}
private resolveLinkUrl(target: Element | null): string {
const link = target?.closest('a[href]');
return link instanceof HTMLAnchorElement ? link.href : '';
}
private resolveImageUrl(target: Element | null): string {
const imageTarget = target instanceof HTMLImageElement
? target
: target?.closest('img');
return imageTarget instanceof HTMLImageElement
? imageTarget.currentSrc || imageTarget.src
: '';
}
private isTextControl(target: ContextMenuTarget | null): target is TextControlElement {
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
}
private isContentEditableTarget(target: ContextMenuTarget | null): target is HTMLElement {
return target instanceof HTMLElement && target.isContentEditable;
}
private isDisabledTarget(target: ContextMenuTarget | null): boolean {
return this.isTextControl(target) ? target.disabled : false;
}
private canWriteToTarget(target: ContextMenuTarget | null): boolean {
if (this.isTextControl(target)) {
return !target.disabled && !target.readOnly;
}
return this.isContentEditableTarget(target);
}
private canReadClipboard(): boolean {
return typeof navigator !== 'undefined'
&& (!!navigator.clipboard?.readText || this.electronBridge.isAvailable);
}
}

View File

@@ -46,7 +46,7 @@
name="lucideRefreshCw"
class="h-3.5 w-3.5 animate-spin"
/>
Reconnecting to signal server
Reconnecting to signal server...
</span>
}
@@ -65,7 +65,7 @@
<span
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
[class.hidden]="!isReconnecting()"
>Reconnecting</span
>Reconnecting...</span
>
</div>
}
@@ -110,7 +110,7 @@
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
@if (creatingInvite()) {
Creating Invite Link
Creating Invite Link...
} @else {
Create Invite Link
}

View File

@@ -193,7 +193,7 @@ export class TitleBarComponent {
}
this.creatingInvite.set(true);
this.inviteStatus.set('Creating invite link');
this.inviteStatus.set('Creating invite link...');
try {
const invite = await firstValueFrom(this.serverDirectory.createInvite(

View File

@@ -1,6 +1,6 @@
# Realtime Infrastructure
Low-level WebRTC and WebSocket plumbing that the rest of the app sits on top of. Nothing in here knows about Angular components, NgRx, or domain logic. It exposes observables, signals, and callbacks that higher layers (facades, effects, components) consume.
Low-level WebRTC and WebSocket plumbing plus the Angular-facing runtime boundary that the rest of the app sits on top of. Most files here stay technical and framework-light, but this area does use Angular signals and DI, shared-kernel contracts, and a small screen-share domain adapter at the composition boundary. It exposes observables, signals, and callbacks that higher layers (facades, effects, components) consume.
## Module map
@@ -9,6 +9,8 @@ realtime/
├── realtime-session.service.ts Composition root (WebRTCService)
├── realtime.types.ts PeerData, credentials, tracker types
├── realtime.constants.ts ICE servers, signal types, bitrates, intervals
├── ice-server-settings.service.ts Persisted STUN/TURN configuration
├── screen-share.config.ts Shared screen-share options and presets
├── signaling/ WebSocket layer
│ ├── signaling.manager.ts One WebSocket per signaling URL
@@ -56,7 +58,7 @@ realtime/
## How it all fits together
`WebRTCService` is the composition root. It instantiates every other manager, then wires their callbacks together after construction (to avoid circular references). No manager imports another manager directly.
`WebRTCService` is the composition root. It instantiates the main managers, then wires their callbacks together after construction to avoid the old monolithic tangle and break circular initialization. Some focused helpers still depend on other managers or their types, but cross-cutting orchestration stays centralized here instead of being spread across the runtime.
```mermaid
graph TD
@@ -378,9 +380,10 @@ Instead of connecting peers directly:
This approach is more reliable in restrictive network environments but introduces additional latency and bandwidth overhead, since all traffic flows through the relay instead of directly between peers.
Toju/Zoracord does not use TURN and does not have code written to support it.
MetoYou ships with STUN-only defaults in `ICE_SERVERS`, but the runtime does support TURN entries through `IceServerSettingsService` and standard `RTCIceServer` credentials. There is no bundled TURN service or default TURN configuration in the repo.
### Summary
- **ICE** coordinates connection establishment by trying multiple network paths
- **STUN** provides public-facing address discovery for NAT traversal
- **TURN** is an optional relay fallback that this runtime can be configured to use, but it is not bundled or enabled by default

View File

@@ -0,0 +1,122 @@
import {
Injectable,
signal,
computed,
type Signal
} from '@angular/core';
import { STORAGE_KEY_ICE_SERVERS } from '../../core/constants';
import { ICE_SERVERS } from './realtime.constants';
export interface IceServerEntry {
id: string;
type: 'stun' | 'turn';
urls: string;
username?: string;
credential?: string;
}
const DEFAULT_ENTRIES: IceServerEntry[] = ICE_SERVERS.map((server, index) => ({
id: `default-stun-${index}`,
type: 'stun' as const,
urls: Array.isArray(server.urls) ? server.urls[0] : server.urls
}));
@Injectable({ providedIn: 'root' })
export class IceServerSettingsService {
readonly entries: Signal<IceServerEntry[]>;
readonly rtcIceServers: Signal<RTCIceServer[]>;
private readonly _entries = signal<IceServerEntry[]>(this.load());
constructor() {
this.entries = this._entries.asReadonly();
this.rtcIceServers = computed<RTCIceServer[]>(() =>
this._entries().map((entry) => {
if (entry.type === 'turn') {
return {
urls: entry.urls,
username: entry.username ?? '',
credential: entry.credential ?? ''
};
}
return { urls: entry.urls };
})
);
}
addEntry(entry: Omit<IceServerEntry, 'id'>): void {
const id = `${entry.type}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
const updated = [...this._entries(), { ...entry, id }];
this._entries.set(updated);
this.save(updated);
}
removeEntry(id: string): void {
const updated = this._entries().filter((entry) => entry.id !== id);
this._entries.set(updated);
this.save(updated);
}
updateEntry(id: string, changes: Partial<Omit<IceServerEntry, 'id'>>): void {
const updated = this._entries().map((entry) =>
entry.id === id ? { ...entry, ...changes } : entry
);
this._entries.set(updated);
this.save(updated);
}
moveEntry(fromIndex: number, toIndex: number): void {
const entries = [...this._entries()];
if (fromIndex < 0 || fromIndex >= entries.length || toIndex < 0 || toIndex >= entries.length) {
return;
}
const [moved] = entries.splice(fromIndex, 1);
entries.splice(toIndex, 0, moved);
this._entries.set(entries);
this.save(entries);
}
restoreDefaults(): void {
this._entries.set([...DEFAULT_ENTRIES]);
this.save(DEFAULT_ENTRIES);
}
private load(): IceServerEntry[] {
try {
const raw = localStorage.getItem(STORAGE_KEY_ICE_SERVERS);
if (!raw) {
return [...DEFAULT_ENTRIES];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length === 0) {
return [...DEFAULT_ENTRIES];
}
return parsed.filter(
(entry: unknown): entry is IceServerEntry =>
typeof entry === 'object'
&& entry !== null
&& typeof (entry as IceServerEntry).id === 'string'
&& ((entry as IceServerEntry).type === 'stun' || (entry as IceServerEntry).type === 'turn')
&& typeof (entry as IceServerEntry).urls === 'string'
);
} catch {
return [...DEFAULT_ENTRIES];
}
}
private save(entries: IceServerEntry[]): void {
localStorage.setItem(STORAGE_KEY_ICE_SERVERS, JSON.stringify(entries));
}
}

View File

@@ -10,6 +10,7 @@ import { LatencyProfile } from '../realtime.constants';
import { PeerData } from '../realtime.types';
import { WebRTCLogger } from '../logging/webrtc-logger';
import { NoiseReductionManager } from './noise-reduction.manager';
import { loadVoiceSettingsFromStorage } from '../../../domains/voice-session/infrastructure/util/voice-settings-storage.util';
import {
TRACK_KIND_AUDIO,
TRACK_KIND_VIDEO,
@@ -105,6 +106,12 @@ export class MediaManager {
private callbacks: MediaManagerCallbacks
) {
this.noiseReduction = new NoiseReductionManager(logger);
// Read the persisted noise-reduction preference so enableVoice()
// uses the correct value even before voice-controls loads.
try {
this._noiseReductionDesired = loadVoiceSettingsFromStorage().noiseReduction;
} catch { /* keep default */ }
}
/**
@@ -226,7 +233,7 @@ export class MediaManager {
: stream;
// Apply input gain (mic volume) before sending to peers
this.applyInputGainToCurrentStream();
await this.applyInputGainToCurrentStream();
this.logger.logStream('localVoice', this.localMediaStream);
@@ -296,7 +303,7 @@ export class MediaManager {
}
// Apply input gain (mic volume) before sending to peers
this.applyInputGainToCurrentStream();
await this.applyInputGainToCurrentStream();
this.bindLocalTracksToAllPeers();
this.isVoiceActive = true;
@@ -447,7 +454,7 @@ export class MediaManager {
}
// Re-apply input gain to the (possibly new) stream
this.applyInputGainToCurrentStream();
await this.applyInputGainToCurrentStream();
// Propagate the new audio track to every peer connection
this.bindLocalTracksToAllPeers();
@@ -479,8 +486,7 @@ export class MediaManager {
}
if (this.localMediaStream) {
this.applyInputGainToCurrentStream();
this.bindLocalTracksToAllPeers();
void this.applyInputGainToCurrentStream().then(() => this.bindLocalTracksToAllPeers());
}
}
@@ -840,12 +846,22 @@ export class MediaManager {
* If a gain pipeline already exists for the same source stream the gain
* value is simply updated. Otherwise a new pipeline is created.
*/
private applyInputGainToCurrentStream(): void {
private async applyInputGainToCurrentStream(): Promise<void> {
const stream = this.localMediaStream;
if (!stream)
return;
// When gain is unity (1.0) skip the Web Audio pipeline entirely and
// use the raw microphone stream. This avoids unnecessary AudioContext
// overhead when no volume adjustment is needed.
if (this.inputGainVolume === 1.0) {
this.teardownInputGain();
this.preGainStream = stream;
this.applyCurrentMuteState();
return;
}
// If the source stream hasn't changed, just update gain
if (this.preGainStream === stream && this.inputGainNode && this.inputGainCtx) {
this.inputGainNode.gain.value = this.inputGainVolume;
@@ -855,9 +871,15 @@ export class MediaManager {
// Tear down the old pipeline (if any)
this.teardownInputGain();
// Build new pipeline: source gain destination
// Build new pipeline: source -> gain -> destination
this.preGainStream = stream;
this.inputGainCtx = new AudioContext();
// Ensure the AudioContext is running before connecting nodes.
if (this.inputGainCtx.state !== 'running') {
await this.inputGainCtx.resume();
}
this.inputGainSourceNode = this.inputGainCtx.createMediaStreamSource(stream);
this.inputGainNode = this.inputGainCtx.createGain();
this.inputGainNode.gain.value = this.inputGainVolume;

View File

@@ -7,9 +7,9 @@
* a clean output stream that can be sent to peers instead.
*
* Architecture:
* raw mic AudioContext.createMediaStreamSource
* NoiseSuppressorWorklet (AudioWorkletNode)
* MediaStreamDestination clean MediaStream
* raw mic -> AudioContext.createMediaStreamSource
* -> NoiseSuppressorWorklet (AudioWorkletNode)
* -> MediaStreamDestination -> clean MediaStream
*
* The manager is intentionally stateless w.r.t. Angular signals;
* the owning MediaManager / WebRTCService drives signals.
@@ -138,7 +138,7 @@ export class NoiseReductionManager {
/**
* Build the AudioWorklet processing graph:
* rawStream source workletNode destination
* rawStream -> source -> workletNode -> destination
*/
private async buildProcessingGraph(rawStream: MediaStream): Promise<void> {
// Reuse or create the AudioContext (must be 48 kHz for RNNoise)

View File

@@ -4,7 +4,6 @@ import {
CONNECTION_STATE_DISCONNECTED,
CONNECTION_STATE_FAILED,
DATA_CHANNEL_LABEL,
ICE_SERVERS,
SIGNALING_TYPE_ICE_CANDIDATE,
TRACK_KIND_AUDIO,
TRACK_KIND_VIDEO,
@@ -28,7 +27,7 @@ export function createPeerConnection(
logger.info('Creating peer connection', { remotePeerId, isInitiator });
const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
const connection = new RTCPeerConnection({ iceServers: callbacks.getIceServers() });
let dataChannel: RTCDataChannel | null = null;
let peerData: PeerData | null = null;

View File

@@ -29,6 +29,8 @@ export interface PeerConnectionCallbacks {
isScreenSharingActive(): boolean;
/** Whether the local camera is active. */
isCameraEnabled(): boolean;
/** Returns the user-configured ICE servers for peer connection creation. */
getIceServers(): RTCIceServer[];
}
export interface PeerConnectionManagerState {

View File

@@ -2,11 +2,11 @@
* WebRTCService - thin Angular service that composes specialised managers.
*
* Each concern lives in its own file under `./`:
* SignalingManager - WebSocket lifecycle & reconnection
* PeerConnectionManager - RTCPeerConnection, offers/answers, ICE, data channels
* MediaManager - mic voice, mute, deafen, bitrate
* ScreenShareManager - screen capture & mixed audio
* WebRTCLogger - debug / diagnostic logging
* - SignalingManager - WebSocket lifecycle & reconnection
* - PeerConnectionManager - RTCPeerConnection, offers/answers, ICE, data channels
* - MediaManager - mic voice, mute, deafen, bitrate
* - ScreenShareManager - screen capture & mixed audio
* - WebRTCLogger - debug / diagnostic logging
*
* This file wires them together and exposes a public API that is
* identical to the old monolithic service so consumers don't change.
@@ -26,6 +26,7 @@ import { ScreenShareSourcePickerService } from '../../domains/screen-share';
import { MediaManager } from './media/media.manager';
import { ScreenShareManager } from './media/screen-share.manager';
import { VoiceSessionController } from './media/voice-session-controller';
import { IceServerSettingsService } from './ice-server-settings.service';
import type { PeerData, VoiceStateSnapshot } from './realtime.types';
import { LatencyProfile } from './realtime.constants';
import { ScreenShareStartOptions } from './screen-share.config';
@@ -47,6 +48,7 @@ export class WebRTCService implements OnDestroy {
private readonly timeSync = inject(TimeSyncService);
private readonly debugging = inject(DebuggingService);
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
private readonly iceServerSettings = inject(IceServerSettingsService);
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
private readonly state = new WebRtcStateController();
@@ -151,7 +153,8 @@ export class WebRTCService implements OnDestroy {
getIdentifyCredentials: () => this.signalingTransportHandler.getIdentifyCredentials(),
getLocalPeerId: (): string => this.state.getLocalPeerId(),
isScreenSharingActive: (): boolean => this.state.isScreenSharingActive(),
isCameraEnabled: (): boolean => this.state.isCameraEnabledActive()
isCameraEnabled: (): boolean => this.state.isCameraEnabledActive(),
getIceServers: (): RTCIceServer[] => this.iceServerSettings.rtcIceServers()
});
this.mediaManager.setCallbacks({
@@ -211,7 +214,7 @@ export class WebRTCService implements OnDestroy {
this.remoteScreenShareRequestController.handlePeerControlMessage(event)
);
// Peer manager connected peers signal
// Peer manager -> connected peers signal
this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) =>
this.state.setConnectedPeers(peers)
);
@@ -232,12 +235,12 @@ export class WebRTCService implements OnDestroy {
this.remoteScreenShareRequestController.handlePeerDisconnected(peerId);
});
// Media manager voice connected signal
// Media manager -> voice connected signal
this.mediaManager.voiceConnected$.subscribe(() => {
this.voiceSessionController.handleVoiceConnected();
});
// Peer manager latency updates
// Peer manager -> latency updates
this.peerManager.peerLatencyChanged$.subscribe(() =>
this.state.syncPeerLatencies(this.peerManager.peerLatencies)
);

View File

@@ -49,7 +49,7 @@ export { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../../shared-kernel';
export const AUDIO_BITRATE_MIN_BPS = 16_000;
/** Maximum audio bitrate (bps) */
export const AUDIO_BITRATE_MAX_BPS = 256_000;
/** Multiplier to convert kbps bps */
/** Multiplier to convert kbps -> bps */
export const KBPS_TO_BPS = 1_000;
/** Pre-defined latency-to-bitrate mappings (bps) */
export const LATENCY_PROFILE_BITRATES: Record<LatencyProfile, number> = {

View File

@@ -60,7 +60,7 @@
></div>
@if (waveformLoading()) {
<div class="audio-waveform-overlay text-muted-foreground">Loading waveform</div>
<div class="audio-waveform-overlay text-muted-foreground">Loading waveform...</div>
} @else if (waveformUnavailable()) {
<div class="audio-waveform-overlay text-muted-foreground">
Couldnt render a waveform preview for this file, but playback still works.

View File

@@ -104,7 +104,7 @@ export class DebugConsoleNetworkMapComponent implements OnDestroy {
}
formatEdgeHeading(edge: DebugNetworkEdge): string {
return `${edge.sourceLabel} ${edge.targetLabel}`;
return `${edge.sourceLabel} -> ${edge.targetLabel}`;
}
formatMessageGroup(group: DebugNetworkMessageGroup): string {
@@ -646,6 +646,6 @@ export class DebugConsoleNetworkMapComponent implements OnDestroy {
if (value.length <= 18)
return value;
return `${value.slice(0, 8)}${value.slice(-6)}`;
return `${value.slice(0, 8)}...${value.slice(-6)}`;
}
}

View File

@@ -230,7 +230,7 @@ export class DebugConsoleExportService {
this.escapeCsvField(edge.sourceLabel),
this.escapeCsvField(edge.targetLabel),
edge.kind,
`${edge.sourceLabel} ${edge.targetLabel}`,
`${edge.sourceLabel} -> ${edge.targetLabel}`,
edge.isActive
].join(',')
);
@@ -353,7 +353,7 @@ export class DebugConsoleExportService {
private appendEdgeTxt(lines: string[], edge: DebugNetworkEdge): void {
const activeLabel = edge.isActive ? 'active' : 'inactive';
lines.push(` [${edge.kind}] ${edge.sourceLabel} ${edge.targetLabel} (${activeLabel})`);
lines.push(` [${edge.kind}] ${edge.sourceLabel} -> ${edge.targetLabel} (${activeLabel})`);
if (edge.pingMs !== null)
lines.push(` Ping: ${edge.pingMs} ms`);
@@ -391,7 +391,7 @@ export class DebugConsoleExportService {
for (const edge of outgoing) {
const target = nodeMap.get(edge.targetId);
lines.push(` ${target?.label ?? edge.targetId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`);
lines.push(` -> ${target?.label ?? edge.targetId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`);
}
}
@@ -401,7 +401,7 @@ export class DebugConsoleExportService {
for (const edge of incoming) {
const source = nodeMap.get(edge.sourceId);
lines.push(` ${source?.label ?? edge.sourceId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`);
lines.push(` <- ${source?.label ?? edge.sourceId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`);
}
}