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
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:
42
toju-app/README.md
Normal file
42
toju-app/README.md
Normal 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.
|
||||
@@ -97,7 +97,7 @@
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2.2MB",
|
||||
"maximumError": "2.32MB"
|
||||
"maximumError": "2.35MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -199,4 +199,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ICE Server Settings (STUN / TURN) -->
|
||||
<app-ice-server-settings />
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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">
|
||||
Couldn’t render a waveform preview for this file, but playback still works.
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ? '●' : '○'}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user