Move toju-app into own its folder
This commit is contained in:
111
toju-app/src/app/domains/voice-session/README.md
Normal file
111
toju-app/src/app/domains/voice-session/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Voice Session Domain
|
||||
|
||||
Tracks voice session metadata across client-side navigation and manages the voice workspace UI state (expanded, minimized, hidden). This domain does not touch WebRTC directly; actual connections live in `voice-connection` and `infrastructure/realtime`.
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
voice-session/
|
||||
├── application/
|
||||
│ ├── voice-session.facade.ts Tracks active voice session, drives floating controls
|
||||
│ └── voice-workspace.service.ts Workspace mode (hidden/expanded/minimized), focused stream, mini-window position
|
||||
│
|
||||
├── domain/
|
||||
│ ├── voice-session.logic.ts isViewingVoiceSessionServer, buildVoiceSessionRoom
|
||||
│ └── voice-session.models.ts VoiceSessionInfo interface
|
||||
│
|
||||
├── infrastructure/
|
||||
│ └── voice-settings.storage.ts Persists audio device IDs, volumes, bitrate, latency, noise reduction to localStorage
|
||||
│
|
||||
├── feature/
|
||||
│ ├── voice-controls/ Full voice control panel (mic, deafen, devices, screen share, settings)
|
||||
│ └── floating-voice-controls/ Minimal overlay when user navigates away from the voice server
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
```
|
||||
|
||||
## How the pieces connect
|
||||
|
||||
The facade manages session bookkeeping. The workspace service owns view state. Settings storage provides persistence for user preferences. Neither service opens any WebRTC connections.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
VSF[VoiceSessionFacade]
|
||||
VWS[VoiceWorkspaceService]
|
||||
VSS[voiceSettingsStorage]
|
||||
Logic[voice-session.logic]
|
||||
VC[VoiceControlsComponent]
|
||||
FC[FloatingVoiceControlsComponent]
|
||||
Store[NgRx Store]
|
||||
|
||||
VC --> VSF
|
||||
VC --> VWS
|
||||
VC --> VSS
|
||||
FC --> VSF
|
||||
FC --> VWS
|
||||
VSF --> Logic
|
||||
VSF --> Store
|
||||
VWS --> VSF
|
||||
|
||||
click VSF "application/voice-session.facade.ts" "Tracks active voice session" _blank
|
||||
click VWS "application/voice-workspace.service.ts" "Workspace mode and focused stream" _blank
|
||||
click VSS "infrastructure/voice-settings.storage.ts" "localStorage persistence for audio settings" _blank
|
||||
click Logic "domain/voice-session.logic.ts" "Pure helper functions" _blank
|
||||
click VC "feature/voice-controls/" "Full voice control panel" _blank
|
||||
click FC "feature/floating-voice-controls/" "Minimal floating overlay" _blank
|
||||
```
|
||||
|
||||
## Session lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> NoSession
|
||||
NoSession --> Active: startSession(info)
|
||||
Active --> Active: checkCurrentRoute(serverId)
|
||||
Active --> NoSession: endSession()
|
||||
|
||||
state Active {
|
||||
[*] --> ViewingServer
|
||||
ViewingServer --> AwayFromServer: navigated to different server
|
||||
AwayFromServer --> ViewingServer: navigated back / navigateToVoiceServer()
|
||||
}
|
||||
```
|
||||
|
||||
When a voice session is active and the user navigates away from the voice-connected server, `showFloatingControls` becomes `true` and the floating overlay appears. Clicking the overlay dispatches `RoomsActions.viewServer` to navigate back.
|
||||
|
||||
## Workspace modes
|
||||
|
||||
`VoiceWorkspaceService` controls the voice workspace panel state. The workspace is only visible when the user is viewing the voice-connected server.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Hidden
|
||||
Hidden --> Expanded: open()
|
||||
Expanded --> Minimized: minimize()
|
||||
Expanded --> Hidden: close() / showChat()
|
||||
Minimized --> Expanded: restore()
|
||||
Minimized --> Hidden: close()
|
||||
Expanded --> Hidden: voice session ends
|
||||
Minimized --> Hidden: voice session ends
|
||||
```
|
||||
|
||||
The minimized mode renders a draggable mini-window. Its position is tracked in `miniWindowPosition` and clamped to viewport bounds on resize. `focusedStreamId` controls which screen-share stream gets the widescreen treatment in expanded mode.
|
||||
|
||||
## Voice settings
|
||||
|
||||
Settings are stored in localStorage under a single JSON key. All values are validated and clamped on load to defend against corrupt storage.
|
||||
|
||||
| Setting | Default | Range |
|
||||
|---|---|---|
|
||||
| inputDevice | `""` | device ID string |
|
||||
| outputDevice | `""` | device ID string |
|
||||
| inputVolume | 100 | 0 -- 100 |
|
||||
| outputVolume | 100 | 0 -- 100 |
|
||||
| audioBitrate | 96 kbps | 32 -- 256 |
|
||||
| latencyProfile | `"balanced"` | low / balanced / high |
|
||||
| noiseReduction | `true` | boolean |
|
||||
| screenShareQuality | `"balanced"` | low / balanced / high |
|
||||
| askScreenShareQuality | `true` | boolean |
|
||||
| includeSystemAudio | `false` | boolean |
|
||||
|
||||
`loadVoiceSettingsFromStorage()` and `saveVoiceSettingsToStorage(patch)` are the only entry points. The save function merges the patch with the current stored value so callers only need to pass changed fields.
|
||||
@@ -0,0 +1,114 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, */
|
||||
import {
|
||||
Injectable,
|
||||
signal,
|
||||
computed,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||
import { buildVoiceSessionRoom, isViewingVoiceSessionServer } from '../domain/voice-session.logic';
|
||||
import type { VoiceSessionInfo } from '../domain/voice-session.models';
|
||||
|
||||
/**
|
||||
* Tracks the user's current voice session across client-side
|
||||
* navigation so that floating voice controls remain visible when
|
||||
* the user is browsing a different server or view.
|
||||
*
|
||||
* This service is purely a UI-state tracker - actual WebRTC
|
||||
* voice management lives in {@link WebRTCService} and its managers.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceSessionFacade {
|
||||
private readonly store = inject(Store);
|
||||
|
||||
/** Current voice session metadata, or `null` when disconnected. */
|
||||
private readonly _voiceSession = signal<VoiceSessionInfo | null>(null);
|
||||
|
||||
/** Whether the user is currently viewing the voice-connected server. */
|
||||
private readonly _isViewingVoiceServer = signal<boolean>(true);
|
||||
|
||||
/** Reactive read-only voice session. */
|
||||
readonly voiceSession = computed(() => this._voiceSession());
|
||||
|
||||
/** Reactive flag: is the user's current view the voice server? */
|
||||
readonly isViewingVoiceServer = computed(() => this._isViewingVoiceServer());
|
||||
|
||||
/**
|
||||
* Whether the floating voice-controls overlay should be visible.
|
||||
* `true` when a voice session is active AND the user is viewing
|
||||
* a different server.
|
||||
*/
|
||||
readonly showFloatingControls = computed(
|
||||
() => this._voiceSession() !== null && !this._isViewingVoiceServer()
|
||||
);
|
||||
|
||||
/**
|
||||
* Begin tracking a voice session.
|
||||
* Called when the user joins a voice channel.
|
||||
*
|
||||
* @param sessionInfo - Metadata describing the voice-connected server/channel.
|
||||
*/
|
||||
startSession(sessionInfo: VoiceSessionInfo): void {
|
||||
this._voiceSession.set(sessionInfo);
|
||||
this._isViewingVoiceServer.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop tracking the voice session.
|
||||
* Called when the user disconnects from voice.
|
||||
*/
|
||||
endSession(): void {
|
||||
this._voiceSession.set(null);
|
||||
this._isViewingVoiceServer.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually flag whether the user is currently viewing the
|
||||
* voice-connected server.
|
||||
*
|
||||
* @param isViewing - `true` if the user's current view is the voice server.
|
||||
*/
|
||||
setViewingVoiceServer(isViewing: boolean): void {
|
||||
this._isViewingVoiceServer.set(isViewing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the given server ID to the voice session's server and
|
||||
* update the {@link isViewingVoiceServer} flag accordingly.
|
||||
*
|
||||
* @param currentServerId - ID of the server the user is currently viewing.
|
||||
*/
|
||||
checkCurrentRoute(currentServerId: string | null): void {
|
||||
this._isViewingVoiceServer.set(
|
||||
isViewingVoiceSessionServer(this._voiceSession(), currentServerId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate the user back to the voice-connected server by
|
||||
* dispatching a `viewServer` action.
|
||||
*/
|
||||
navigateToVoiceServer(): void {
|
||||
const session = this._voiceSession();
|
||||
|
||||
if (!session)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.viewServer({
|
||||
room: buildVoiceSessionRoom(session)
|
||||
})
|
||||
);
|
||||
|
||||
this._isViewingVoiceServer.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the server ID of the active voice session, or `null`
|
||||
* if the user is not in a voice channel.
|
||||
*/
|
||||
getVoiceServerId(): string | null {
|
||||
return this._voiceSession()?.serverId ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
|
||||
import { VoiceSessionFacade } from './voice-session.facade';
|
||||
|
||||
export type VoiceWorkspaceMode = 'hidden' | 'expanded' | 'minimized';
|
||||
|
||||
export interface VoiceWorkspacePosition {
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MINI_WINDOW_POSITION: VoiceWorkspacePosition = {
|
||||
left: 24,
|
||||
top: 24
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceWorkspaceService {
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
|
||||
private readonly _mode = signal<VoiceWorkspaceMode>('hidden');
|
||||
private readonly _focusedStreamId = signal<string | null>(null);
|
||||
private readonly _connectRemoteShares = signal(false);
|
||||
private readonly _miniWindowPosition = signal<VoiceWorkspacePosition>(
|
||||
DEFAULT_MINI_WINDOW_POSITION
|
||||
);
|
||||
private readonly _hasCustomMiniWindowPosition = signal(false);
|
||||
|
||||
readonly mode = computed<VoiceWorkspaceMode>(() => {
|
||||
if (!this.voiceSession.voiceSession() || !this.voiceSession.isViewingVoiceServer()) {
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
return this._mode();
|
||||
});
|
||||
|
||||
readonly isExpanded = computed(() => this.mode() === 'expanded');
|
||||
readonly isMinimized = computed(() => this.mode() === 'minimized');
|
||||
readonly isVisible = computed(() => this.mode() !== 'hidden');
|
||||
readonly focusedStreamId = computed(() => this._focusedStreamId());
|
||||
readonly shouldConnectRemoteShares = computed(
|
||||
() => this.isVisible() && this._connectRemoteShares()
|
||||
);
|
||||
readonly miniWindowPosition = computed(() => this._miniWindowPosition());
|
||||
readonly hasCustomMiniWindowPosition = computed(() => this._hasCustomMiniWindowPosition());
|
||||
|
||||
constructor() {
|
||||
effect(
|
||||
() => {
|
||||
if (this.voiceSession.voiceSession()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reset();
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
}
|
||||
|
||||
open(
|
||||
focusedStreamId: string | null = null,
|
||||
options?: { connectRemoteShares?: boolean }
|
||||
): void {
|
||||
if (!this.voiceSession.voiceSession()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options && Object.prototype.hasOwnProperty.call(options, 'connectRemoteShares')) {
|
||||
this._connectRemoteShares.set(options.connectRemoteShares === true);
|
||||
}
|
||||
|
||||
this._focusedStreamId.set(focusedStreamId);
|
||||
this._mode.set('expanded');
|
||||
}
|
||||
|
||||
focusStream(streamId: string, options?: { connectRemoteShares?: boolean }): void {
|
||||
this.open(streamId, options);
|
||||
}
|
||||
|
||||
minimize(): void {
|
||||
if (!this.voiceSession.voiceSession()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._mode.set('minimized');
|
||||
}
|
||||
|
||||
restore(): void {
|
||||
this.open(this._focusedStreamId());
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this._mode.set('hidden');
|
||||
this._connectRemoteShares.set(false);
|
||||
}
|
||||
|
||||
showChat(): void {
|
||||
if (this._mode() === 'expanded') {
|
||||
this._mode.set('hidden');
|
||||
this._connectRemoteShares.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
clearFocusedStream(): void {
|
||||
this._focusedStreamId.set(null);
|
||||
}
|
||||
|
||||
setMiniWindowPosition(position: VoiceWorkspacePosition, markCustom = true): void {
|
||||
this._miniWindowPosition.set(position);
|
||||
this._hasCustomMiniWindowPosition.set(markCustom);
|
||||
}
|
||||
|
||||
resetMiniWindowPosition(): void {
|
||||
this._miniWindowPosition.set(DEFAULT_MINI_WINDOW_POSITION);
|
||||
this._hasCustomMiniWindowPosition.set(false);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._mode.set('hidden');
|
||||
this._focusedStreamId.set(null);
|
||||
this._connectRemoteShares.set(false);
|
||||
this.resetMiniWindowPosition();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Room } from '../../../shared-kernel';
|
||||
import type { VoiceSessionInfo } from './voice-session.models';
|
||||
|
||||
export function isViewingVoiceSessionServer(
|
||||
session: VoiceSessionInfo | null,
|
||||
currentServerId: string | null
|
||||
): boolean {
|
||||
return !session || currentServerId === session.serverId;
|
||||
}
|
||||
|
||||
export function buildVoiceSessionRoom(session: VoiceSessionInfo): Room {
|
||||
return {
|
||||
id: session.serverId,
|
||||
name: session.serverName,
|
||||
description: session.serverDescription,
|
||||
hostId: '',
|
||||
isPrivate: false,
|
||||
createdAt: 0,
|
||||
userCount: 0,
|
||||
maxUsers: 50,
|
||||
icon: session.serverIcon
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Snapshot of an active voice session, retained so that floating
|
||||
* voice controls can display the connection details when the user
|
||||
* navigates away from the server view.
|
||||
*/
|
||||
export interface VoiceSessionInfo {
|
||||
/** Unique server identifier. */
|
||||
serverId: string;
|
||||
/** Display name of the server. */
|
||||
serverName: string;
|
||||
/** Room/channel ID within the server. */
|
||||
roomId: string;
|
||||
/** Display name of the room/channel. */
|
||||
roomName: string;
|
||||
/** Optional server icon (data-URL or remote URL). */
|
||||
serverIcon?: string;
|
||||
/** Optional server description. */
|
||||
serverDescription?: string;
|
||||
/** Angular route path to navigate back to the server. */
|
||||
serverRoute: string;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
@if (showFloatingControls()) {
|
||||
<!-- Centered relative to rooms-side-panel (w-80 = 320px, so right-40 = 160px from right edge = center) -->
|
||||
<div class="fixed bottom-4 right-40 translate-x-1/2 z-50 bg-card border border-border rounded-xl shadow-lg">
|
||||
<div class="p-2 flex items-center gap-2">
|
||||
<!-- Back to server button -->
|
||||
<button
|
||||
(click)="navigateToServer()"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-2 py-1 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-colors"
|
||||
title="Back to {{ voiceSession()?.serverName }}"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideArrowLeft"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
@if (voiceSession()?.serverIcon) {
|
||||
<img
|
||||
[src]="voiceSession()?.serverIcon"
|
||||
class="w-5 h-5 rounded object-cover"
|
||||
alt=""
|
||||
/>
|
||||
} @else {
|
||||
<div class="w-5 h-5 rounded bg-primary/20 flex items-center justify-center text-[10px] font-semibold">
|
||||
{{ voiceSession()?.serverName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Voice status indicator -->
|
||||
<div class="flex items-center gap-1 px-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
|
||||
<span class="text-xs text-muted-foreground max-w-20 truncate">{{ voiceSession()?.roomName || 'Voice' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="w-px h-6 bg-border"></div>
|
||||
|
||||
<!-- Voice controls -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
(click)="toggleMute()"
|
||||
type="button"
|
||||
[class]="getCompactButtonClass(isMuted())"
|
||||
title="Toggle Mute"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isMuted() ? 'lucideMicOff' : 'lucideMic'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="toggleDeafen()"
|
||||
type="button"
|
||||
[class]="getCompactButtonClass(isDeafened())"
|
||||
title="Toggle Deafen"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideHeadphones"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="toggleScreenShare()"
|
||||
type="button"
|
||||
[class]="getCompactScreenShareClass()"
|
||||
title="Toggle Screen Share"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<app-debug-console
|
||||
launcherVariant="compact"
|
||||
[showPanel]="false"
|
||||
/>
|
||||
|
||||
<button
|
||||
(click)="disconnect()"
|
||||
type="button"
|
||||
class="w-7 h-7 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
|
||||
title="Disconnect"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePhoneOff"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showScreenShareQualityDialog()) {
|
||||
<app-screen-share-quality-dialog
|
||||
[selectedQuality]="screenShareQuality()"
|
||||
[includeSystemAudio]="includeSystemAudio()"
|
||||
(cancelled)="onScreenShareQualityCancelled()"
|
||||
(confirmed)="onScreenShareQualityConfirmed($event)"
|
||||
/>
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideHeadphones,
|
||||
lucideArrowLeft
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { VoiceSessionFacade } from '../../application/voice-session.facade';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/voice-settings.storage';
|
||||
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
||||
import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
|
||||
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-floating-voice-controls',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
DebugConsoleComponent,
|
||||
ScreenShareQualityDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideHeadphones,
|
||||
lucideArrowLeft
|
||||
})
|
||||
],
|
||||
templateUrl: './floating-voice-controls.component.html'
|
||||
})
|
||||
/**
|
||||
* Floating voice controls displayed when the user navigates away from the voice-connected server.
|
||||
* Provides mute, deafen, screen-share, and disconnect actions in a compact overlay.
|
||||
*/
|
||||
export class FloatingVoiceControlsComponent implements OnInit {
|
||||
private readonly webrtcService = inject(VoiceConnectionFacade);
|
||||
private readonly screenShareService = inject(ScreenShareFacade);
|
||||
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly store = inject(Store);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
// Voice state from services
|
||||
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
||||
voiceSession = this.voiceSessionService.voiceSession;
|
||||
|
||||
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
||||
isMuted = signal(false);
|
||||
isDeafened = signal(false);
|
||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||
includeSystemAudio = signal(false);
|
||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
askScreenShareQuality = signal(true);
|
||||
showScreenShareQualityDialog = signal(false);
|
||||
|
||||
/** Sync local mute/deafen state from the WebRTC service on init. */
|
||||
ngOnInit(): void {
|
||||
// Sync mute/deafen state from webrtc service
|
||||
this.isMuted.set(this.webrtcService.isMuted());
|
||||
this.isDeafened.set(this.webrtcService.isDeafened());
|
||||
this.syncScreenShareSettings();
|
||||
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.voicePlayback.updateOutputVolume(settings.outputVolume / 100);
|
||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||
|
||||
if (settings.outputDevice) {
|
||||
this.voicePlayback.applyOutputDevice(settings.outputDevice);
|
||||
}
|
||||
}
|
||||
|
||||
/** Navigate back to the voice-connected server. */
|
||||
navigateToServer(): void {
|
||||
this.voiceSessionService.navigateToVoiceServer();
|
||||
}
|
||||
|
||||
/** Toggle microphone mute and broadcast the updated voice state. */
|
||||
toggleMute(): void {
|
||||
this.isMuted.update((current) => !current);
|
||||
this.webrtcService.toggleMute(this.isMuted());
|
||||
|
||||
// Broadcast mute state change
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle deafen state (muting audio output) and broadcast the updated voice state. */
|
||||
toggleDeafen(): void {
|
||||
this.isDeafened.update((current) => !current);
|
||||
this.webrtcService.toggleDeafen(this.isDeafened());
|
||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||
|
||||
// When deafening, also mute
|
||||
if (this.isDeafened() && !this.isMuted()) {
|
||||
this.isMuted.set(true);
|
||||
this.webrtcService.toggleMute(true);
|
||||
}
|
||||
|
||||
// Broadcast deafen state change
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle screen sharing on or off. */
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
if (this.isScreenSharing()) {
|
||||
this.screenShareService.stopScreenShare();
|
||||
} else {
|
||||
this.syncScreenShareSettings();
|
||||
|
||||
if (this.askScreenShareQuality()) {
|
||||
this.showScreenShareQualityDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startScreenShareWithOptions(this.screenShareQuality());
|
||||
}
|
||||
}
|
||||
|
||||
onScreenShareQualityCancelled(): void {
|
||||
this.showScreenShareQualityDialog.set(false);
|
||||
}
|
||||
|
||||
async onScreenShareQualityConfirmed(quality: ScreenShareQuality): Promise<void> {
|
||||
this.showScreenShareQualityDialog.set(false);
|
||||
this.screenShareQuality.set(quality);
|
||||
saveVoiceSettingsToStorage({ screenShareQuality: quality });
|
||||
await this.startScreenShareWithOptions(quality);
|
||||
}
|
||||
|
||||
/** Disconnect from the voice session entirely, cleaning up all voice state. */
|
||||
disconnect(): void {
|
||||
// Stop voice heartbeat
|
||||
this.webrtcService.stopVoiceHeartbeat();
|
||||
|
||||
// Broadcast voice disconnect
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false
|
||||
}
|
||||
});
|
||||
|
||||
// Stop screen sharing if active
|
||||
if (this.isScreenSharing()) {
|
||||
this.screenShareService.stopScreenShare();
|
||||
}
|
||||
|
||||
// Disable voice
|
||||
this.webrtcService.disableVoice();
|
||||
this.voicePlayback.teardownAll();
|
||||
this.voicePlayback.updateDeafened(false);
|
||||
|
||||
// Update user voice state in store
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: { isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined }
|
||||
}));
|
||||
}
|
||||
|
||||
// End voice session
|
||||
this.voiceSessionService.endSession();
|
||||
|
||||
// Reset local state
|
||||
this.isMuted.set(false);
|
||||
this.isDeafened.set(false);
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the compact control button based on active state. */
|
||||
getCompactButtonClass(isActive: boolean): string {
|
||||
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
||||
|
||||
if (isActive) {
|
||||
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the compact screen-share button. */
|
||||
getCompactScreenShareClass(): string {
|
||||
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the mute toggle button. */
|
||||
getMuteButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
|
||||
if (this.isMuted()) {
|
||||
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the deafen toggle button. */
|
||||
getDeafenButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
|
||||
if (this.isDeafened()) {
|
||||
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the screen-share toggle button. */
|
||||
getScreenShareButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
private syncScreenShareSettings(): void {
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.includeSystemAudio.set(settings.includeSystemAudio);
|
||||
this.screenShareQuality.set(settings.screenShareQuality);
|
||||
this.askScreenShareQuality.set(settings.askScreenShareQuality);
|
||||
}
|
||||
|
||||
private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise<void> {
|
||||
try {
|
||||
await this.screenShareService.startScreenShare({
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
quality
|
||||
});
|
||||
} catch (_error) {
|
||||
// Screen share request was denied or failed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<div class="bg-card border-border p-4">
|
||||
<!-- Connection Error Banner -->
|
||||
@if (showConnectionError()) {
|
||||
<div class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-destructive animate-pulse"></span>
|
||||
<span class="text-xs text-destructive">{{ connectionErrorMessage() || 'Connection error' }}</span>
|
||||
<button
|
||||
type="button"
|
||||
(click)="retryConnection()"
|
||||
class="ml-auto text-xs text-destructive hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<app-user-avatar
|
||||
[name]="currentUser()?.displayName || '?'"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm text-foreground truncate">
|
||||
{{ currentUser()?.displayName || 'Unknown' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
@if (showConnectionError()) {
|
||||
<span class="text-destructive">● Connection Error</span>
|
||||
} @else if (isConnected()) {
|
||||
<span class="text-green-500">● Connected</span>
|
||||
} @else {
|
||||
<span class="text-muted-foreground">● Disconnected</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<app-debug-console
|
||||
launcherVariant="inline"
|
||||
[showPanel]="false"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleSettings()"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSettings"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voice Controls -->
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
@if (isConnected()) {
|
||||
<!-- Mute Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMute()"
|
||||
[class]="getMuteButtonClass()"
|
||||
>
|
||||
@if (isMuted()) {
|
||||
<ng-icon
|
||||
name="lucideMicOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Deafen Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleDeafen()"
|
||||
[class]="getDeafenButtonClass()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideHeadphones"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Screen Share Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleScreenShare()"
|
||||
[class]="getScreenShareButtonClass()"
|
||||
>
|
||||
@if (isScreenSharing()) {
|
||||
<ng-icon
|
||||
name="lucideMonitorOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Disconnect -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="disconnect()"
|
||||
class="w-10 h-10 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePhoneOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showScreenShareQualityDialog()) {
|
||||
<app-screen-share-quality-dialog
|
||||
[selectedQuality]="screenShareQuality()"
|
||||
[includeSystemAudio]="includeSystemAudio()"
|
||||
(cancelled)="onScreenShareQualityCancelled()"
|
||||
(confirmed)="onScreenShareQualityConfirmed($event)"
|
||||
/>
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
computed
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideVideo,
|
||||
lucideVideoOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideSettings,
|
||||
lucideHeadphones
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { VoiceSessionFacade } from '../../application/voice-session.facade';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/voice-settings.storage';
|
||||
import { VoiceActivityService, VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
||||
import { PlaybackOptions, VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
|
||||
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import {
|
||||
DebugConsoleComponent,
|
||||
ScreenShareQualityDialogComponent,
|
||||
UserAvatarComponent
|
||||
} from '../../../../shared';
|
||||
|
||||
interface AudioDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-voice-controls',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
DebugConsoleComponent,
|
||||
ScreenShareQualityDialogComponent,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideVideo,
|
||||
lucideVideoOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideSettings,
|
||||
lucideHeadphones
|
||||
})
|
||||
],
|
||||
templateUrl: './voice-controls.component.html'
|
||||
})
|
||||
export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private readonly webrtcService = inject(VoiceConnectionFacade);
|
||||
private readonly screenShareService = inject(ScreenShareFacade);
|
||||
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly settingsModal = inject(SettingsModalService);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
||||
showConnectionError = computed(() => this.webrtcService.shouldShowConnectionError());
|
||||
connectionErrorMessage = computed(() => this.webrtcService.connectionErrorMessage());
|
||||
isMuted = signal(false);
|
||||
isDeafened = signal(false);
|
||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||
showSettings = signal(false);
|
||||
|
||||
inputDevices = signal<AudioDevice[]>([]);
|
||||
outputDevices = signal<AudioDevice[]>([]);
|
||||
selectedInputDevice = signal<string>('');
|
||||
selectedOutputDevice = signal<string>('');
|
||||
inputVolume = signal(100);
|
||||
outputVolume = signal(100);
|
||||
audioBitrate = signal(96);
|
||||
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
|
||||
includeSystemAudio = signal(false);
|
||||
noiseReduction = signal(true);
|
||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
askScreenShareQuality = signal(true);
|
||||
showScreenShareQualityDialog = signal(false);
|
||||
|
||||
private playbackOptions(): PlaybackOptions {
|
||||
return {
|
||||
isConnected: this.isConnected(),
|
||||
outputVolume: this.outputVolume() / 100,
|
||||
isDeafened: this.isDeafened()
|
||||
};
|
||||
}
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.loadAudioDevices();
|
||||
|
||||
// Load persisted voice settings and apply
|
||||
this.loadSettings();
|
||||
this.applySettingsToWebRTC();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (!this.webrtcService.isVoiceConnected()) {
|
||||
this.voicePlayback.teardownAll();
|
||||
}
|
||||
}
|
||||
|
||||
async loadAudioDevices(): Promise<void> {
|
||||
try {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||
return;
|
||||
}
|
||||
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
this.inputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audioinput')
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
|
||||
this.outputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audiooutput')
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
// Require signaling connectivity first
|
||||
const ok = await this.webrtcService.ensureSignalingConnected();
|
||||
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
deviceId: this.selectedInputDevice() || undefined,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: !this.noiseReduction()
|
||||
}
|
||||
});
|
||||
|
||||
await this.webrtcService.setLocalStream(stream);
|
||||
|
||||
// Track local mic for voice-activity visualisation
|
||||
// Use oderId||id to match the key used by the rooms-side-panel template.
|
||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||
|
||||
if (userId) {
|
||||
this.voiceActivity.trackLocalMic(userId, stream);
|
||||
}
|
||||
|
||||
// Start voice heartbeat to broadcast presence every 5 seconds
|
||||
const room = this.currentRoom();
|
||||
const roomId = this.currentUser()?.voiceState?.roomId || room?.id;
|
||||
const serverId = room?.id;
|
||||
|
||||
this.webrtcService.startVoiceHeartbeat(roomId, serverId);
|
||||
|
||||
// Update local user's voice state in the store so the side panel
|
||||
// shows us in the voice channel with a speaking indicator.
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
roomId,
|
||||
serverId
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Broadcast voice state to other users
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
roomId,
|
||||
serverId
|
||||
}
|
||||
});
|
||||
|
||||
// Play any pending remote streams now that we're connected
|
||||
this.voicePlayback.playPendingStreams(this.playbackOptions());
|
||||
|
||||
// Persist settings after successful connection
|
||||
this.saveSettings();
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
// Retry connection when there's a connection error
|
||||
async retryConnection(): Promise<void> {
|
||||
try {
|
||||
await this.webrtcService.ensureSignalingConnected(10000);
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
// Stop voice heartbeat
|
||||
this.webrtcService.stopVoiceHeartbeat();
|
||||
|
||||
// Broadcast voice disconnect to other users
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
serverId: this.currentRoom()?.id
|
||||
}
|
||||
});
|
||||
|
||||
// Stop screen sharing if active
|
||||
if (this.isScreenSharing()) {
|
||||
this.screenShareService.stopScreenShare();
|
||||
}
|
||||
|
||||
// Untrack local mic from voice-activity visualisation
|
||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||
|
||||
if (userId) {
|
||||
this.voiceActivity.untrackLocalMic(userId);
|
||||
}
|
||||
|
||||
// Disable voice (stops audio tracks but keeps peer connections open for chat)
|
||||
this.webrtcService.disableVoice();
|
||||
this.voicePlayback.teardownAll();
|
||||
this.voicePlayback.updateDeafened(false);
|
||||
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// End voice session for floating controls
|
||||
this.voiceSessionService.endSession();
|
||||
|
||||
this.isMuted.set(false);
|
||||
this.isDeafened.set(false);
|
||||
}
|
||||
|
||||
toggleMute(): void {
|
||||
this.isMuted.update((current) => !current);
|
||||
this.webrtcService.toggleMute(this.isMuted());
|
||||
|
||||
// Update local store so the side panel reflects the mute state
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Broadcast mute state change
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleDeafen(): void {
|
||||
this.isDeafened.update((current) => !current);
|
||||
this.webrtcService.toggleDeafen(this.isDeafened());
|
||||
|
||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||
|
||||
// When deafening, also mute
|
||||
if (this.isDeafened() && !this.isMuted()) {
|
||||
this.isMuted.set(true);
|
||||
this.webrtcService.toggleMute(true);
|
||||
}
|
||||
|
||||
// Broadcast deafen state change
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
|
||||
// Update local store so the side panel reflects the deafen/mute state
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
if (this.isScreenSharing()) {
|
||||
this.screenShareService.stopScreenShare();
|
||||
} else {
|
||||
this.syncScreenShareSettings();
|
||||
|
||||
if (this.askScreenShareQuality()) {
|
||||
this.showScreenShareQualityDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startScreenShareWithOptions(this.screenShareQuality());
|
||||
}
|
||||
}
|
||||
|
||||
onScreenShareQualityCancelled(): void {
|
||||
this.showScreenShareQualityDialog.set(false);
|
||||
}
|
||||
|
||||
async onScreenShareQualityConfirmed(quality: ScreenShareQuality): Promise<void> {
|
||||
this.showScreenShareQualityDialog.set(false);
|
||||
this.screenShareQuality.set(quality);
|
||||
this.saveSettings();
|
||||
await this.startScreenShareWithOptions(quality);
|
||||
}
|
||||
|
||||
toggleSettings(): void {
|
||||
this.settingsModal.open('voice');
|
||||
}
|
||||
|
||||
closeSettings(): void {
|
||||
this.showSettings.set(false);
|
||||
}
|
||||
|
||||
onInputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedInputDevice.set(select.value);
|
||||
|
||||
// Reconnect with new device if connected
|
||||
if (this.isConnected()) {
|
||||
this.disconnect();
|
||||
this.connect();
|
||||
}
|
||||
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onOutputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedOutputDevice.set(select.value);
|
||||
this.applyOutputDevice();
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onInputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.inputVolume.set(parseInt(input.value, 10));
|
||||
this.webrtcService.setInputVolume(this.inputVolume() / 100);
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onOutputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.outputVolume.set(parseInt(input.value, 10));
|
||||
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
|
||||
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onLatencyProfileChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const profile = select.value as 'low' | 'balanced' | 'high';
|
||||
|
||||
this.latencyProfile.set(profile);
|
||||
this.webrtcService.setLatencyProfile(profile);
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onAudioBitrateChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const kbps = parseInt(input.value, 10);
|
||||
|
||||
this.audioBitrate.set(kbps);
|
||||
this.webrtcService.setAudioBitrate(kbps);
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onIncludeSystemAudioChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.includeSystemAudio.set(!!input.checked);
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
async onNoiseReductionChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.noiseReduction.set(!!input.checked);
|
||||
await this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
private loadSettings(): void {
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.selectedInputDevice.set(settings.inputDevice);
|
||||
this.selectedOutputDevice.set(settings.outputDevice);
|
||||
this.inputVolume.set(settings.inputVolume);
|
||||
this.outputVolume.set(settings.outputVolume);
|
||||
this.audioBitrate.set(settings.audioBitrate);
|
||||
this.latencyProfile.set(settings.latencyProfile);
|
||||
this.includeSystemAudio.set(settings.includeSystemAudio);
|
||||
this.noiseReduction.set(settings.noiseReduction);
|
||||
this.screenShareQuality.set(settings.screenShareQuality);
|
||||
this.askScreenShareQuality.set(settings.askScreenShareQuality);
|
||||
}
|
||||
|
||||
private saveSettings(): void {
|
||||
saveVoiceSettingsToStorage({
|
||||
inputDevice: this.selectedInputDevice(),
|
||||
outputDevice: this.selectedOutputDevice(),
|
||||
inputVolume: this.inputVolume(),
|
||||
outputVolume: this.outputVolume(),
|
||||
audioBitrate: this.audioBitrate(),
|
||||
latencyProfile: this.latencyProfile(),
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
noiseReduction: this.noiseReduction(),
|
||||
screenShareQuality: this.screenShareQuality(),
|
||||
askScreenShareQuality: this.askScreenShareQuality()
|
||||
});
|
||||
}
|
||||
|
||||
private applySettingsToWebRTC(): void {
|
||||
try {
|
||||
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
|
||||
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
|
||||
this.webrtcService.setInputVolume(this.inputVolume() / 100);
|
||||
this.webrtcService.setAudioBitrate(this.audioBitrate());
|
||||
this.webrtcService.setLatencyProfile(this.latencyProfile());
|
||||
this.applyOutputDevice();
|
||||
// Always sync the desired noise-reduction preference (even before
|
||||
// a mic stream exists - the flag will be honoured on connect).
|
||||
this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private async applyOutputDevice(): Promise<void> {
|
||||
const deviceId = this.selectedOutputDevice();
|
||||
|
||||
if (!deviceId)
|
||||
return;
|
||||
|
||||
this.voicePlayback.applyOutputDevice(deviceId);
|
||||
}
|
||||
|
||||
private syncScreenShareSettings(): void {
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.includeSystemAudio.set(settings.includeSystemAudio);
|
||||
this.screenShareQuality.set(settings.screenShareQuality);
|
||||
this.askScreenShareQuality.set(settings.askScreenShareQuality);
|
||||
}
|
||||
|
||||
private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise<void> {
|
||||
try {
|
||||
await this.screenShareService.startScreenShare({
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
quality
|
||||
});
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
getMuteButtonClass(): string {
|
||||
const base =
|
||||
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
if (this.isMuted()) {
|
||||
return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`;
|
||||
}
|
||||
|
||||
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
|
||||
}
|
||||
|
||||
getDeafenButtonClass(): string {
|
||||
const base =
|
||||
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
if (this.isDeafened()) {
|
||||
return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`;
|
||||
}
|
||||
|
||||
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
|
||||
}
|
||||
|
||||
getScreenShareButtonClass(): string {
|
||||
const base =
|
||||
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
return `${base} bg-primary/20 text-primary hover:bg-primary/30`;
|
||||
}
|
||||
|
||||
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
|
||||
}
|
||||
}
|
||||
8
toju-app/src/app/domains/voice-session/index.ts
Normal file
8
toju-app/src/app/domains/voice-session/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './application/voice-session.facade';
|
||||
export * from './application/voice-workspace.service';
|
||||
export * from './domain/voice-session.models';
|
||||
export * from './infrastructure/voice-settings.storage';
|
||||
|
||||
// Feature components
|
||||
export { VoiceControlsComponent } from './feature/voice-controls/voice-controls.component';
|
||||
export { FloatingVoiceControlsComponent } from './feature/floating-voice-controls/floating-voice-controls.component';
|
||||
@@ -0,0 +1,99 @@
|
||||
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
|
||||
import {
|
||||
DEFAULT_LATENCY_PROFILE,
|
||||
DEFAULT_SCREEN_SHARE_QUALITY,
|
||||
LATENCY_PROFILES,
|
||||
SCREEN_SHARE_QUALITIES,
|
||||
type LatencyProfile,
|
||||
type ScreenShareQuality
|
||||
} from '../../../shared-kernel';
|
||||
|
||||
export interface VoiceSettings {
|
||||
inputDevice: string;
|
||||
outputDevice: string;
|
||||
inputVolume: number;
|
||||
outputVolume: number;
|
||||
audioBitrate: number;
|
||||
latencyProfile: LatencyProfile;
|
||||
includeSystemAudio: boolean;
|
||||
noiseReduction: boolean;
|
||||
screenShareQuality: ScreenShareQuality;
|
||||
askScreenShareQuality: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_VOICE_SETTINGS: VoiceSettings = {
|
||||
inputDevice: '',
|
||||
outputDevice: '',
|
||||
inputVolume: 100,
|
||||
outputVolume: 100,
|
||||
audioBitrate: 96,
|
||||
latencyProfile: DEFAULT_LATENCY_PROFILE,
|
||||
includeSystemAudio: false,
|
||||
noiseReduction: true,
|
||||
screenShareQuality: DEFAULT_SCREEN_SHARE_QUALITY,
|
||||
askScreenShareQuality: true
|
||||
};
|
||||
|
||||
export function loadVoiceSettingsFromStorage(): VoiceSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
|
||||
|
||||
if (!raw)
|
||||
return { ...DEFAULT_VOICE_SETTINGS };
|
||||
|
||||
return normaliseVoiceSettings(JSON.parse(raw) as Partial<VoiceSettings>);
|
||||
} catch {
|
||||
return { ...DEFAULT_VOICE_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVoiceSettingsToStorage(patch: Partial<VoiceSettings>): VoiceSettings {
|
||||
const nextSettings = normaliseVoiceSettings({
|
||||
...loadVoiceSettingsFromStorage(),
|
||||
...patch
|
||||
});
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_VOICE_SETTINGS, JSON.stringify(nextSettings));
|
||||
} catch {}
|
||||
|
||||
return nextSettings;
|
||||
}
|
||||
|
||||
function normaliseVoiceSettings(raw: Partial<VoiceSettings>): VoiceSettings {
|
||||
return {
|
||||
inputDevice: typeof raw.inputDevice === 'string' ? raw.inputDevice : DEFAULT_VOICE_SETTINGS.inputDevice,
|
||||
outputDevice: typeof raw.outputDevice === 'string' ? raw.outputDevice : DEFAULT_VOICE_SETTINGS.outputDevice,
|
||||
inputVolume: clampNumber(raw.inputVolume, 0, 100, DEFAULT_VOICE_SETTINGS.inputVolume),
|
||||
outputVolume: clampNumber(raw.outputVolume, 0, 200, DEFAULT_VOICE_SETTINGS.outputVolume),
|
||||
audioBitrate: clampNumber(raw.audioBitrate, 32, 256, DEFAULT_VOICE_SETTINGS.audioBitrate),
|
||||
latencyProfile: LATENCY_PROFILES.includes(raw.latencyProfile as LatencyProfile)
|
||||
? raw.latencyProfile as LatencyProfile
|
||||
: DEFAULT_VOICE_SETTINGS.latencyProfile,
|
||||
includeSystemAudio: typeof raw.includeSystemAudio === 'boolean'
|
||||
? raw.includeSystemAudio
|
||||
: DEFAULT_VOICE_SETTINGS.includeSystemAudio,
|
||||
noiseReduction: typeof raw.noiseReduction === 'boolean'
|
||||
? raw.noiseReduction
|
||||
: DEFAULT_VOICE_SETTINGS.noiseReduction,
|
||||
screenShareQuality: SCREEN_SHARE_QUALITIES.includes(raw.screenShareQuality as ScreenShareQuality)
|
||||
? raw.screenShareQuality as ScreenShareQuality
|
||||
: DEFAULT_VOICE_SETTINGS.screenShareQuality,
|
||||
askScreenShareQuality: typeof raw.askScreenShareQuality === 'boolean'
|
||||
? raw.askScreenShareQuality
|
||||
: DEFAULT_VOICE_SETTINGS.askScreenShareQuality
|
||||
};
|
||||
}
|
||||
|
||||
function clampNumber(
|
||||
value: unknown,
|
||||
min: number,
|
||||
max: number,
|
||||
fallback: number
|
||||
): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
Reference in New Issue
Block a user