Fix private calls

This commit is contained in:
2026-05-17 15:14:52 +02:00
parent 0f6cb3ee77
commit e769a6ee4a
71 changed files with 5821 additions and 349 deletions

View File

@@ -0,0 +1,71 @@
<div class="flex w-full flex-wrap items-center justify-center gap-3 rounded-2xl bg-background/75 px-4 py-3 backdrop-blur">
@if (!connected()) {
<button
type="button"
class="inline-flex h-12 items-center gap-2 rounded-full bg-emerald-500 px-6 text-sm font-semibold text-white transition-colors hover:bg-emerald-600"
(click)="joinRequested.emit()"
>
<ng-icon
name="lucidePhone"
class="h-5 w-5"
/>
Join call
</button>
}
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
[disabled]="!connected()"
(click)="muteToggled.emit()"
[attr.aria-label]="muted() ? 'Unmute' : 'Mute'"
[title]="muted() ? 'Unmute' : 'Mute'"
>
<ng-icon
[name]="muted() ? 'lucideMicOff' : 'lucideMic'"
class="h-5 w-5"
/>
</button>
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
[disabled]="!connected()"
(click)="cameraToggled.emit()"
[attr.aria-label]="cameraEnabled() ? 'Turn camera off' : 'Turn camera on'"
[title]="cameraEnabled() ? 'Turn camera off' : 'Turn camera on'"
>
<ng-icon
[name]="cameraEnabled() ? 'lucideVideoOff' : 'lucideVideo'"
class="h-5 w-5"
/>
</button>
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
[disabled]="!connected()"
(click)="screenShareToggled.emit()"
[attr.aria-label]="screenSharing() ? 'Stop sharing screen' : 'Share screen'"
[title]="screenSharing() ? 'Stop sharing screen' : 'Share screen'"
>
<ng-icon
[name]="screenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
class="h-5 w-5"
/>
</button>
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full bg-destructive/10 text-destructive transition-colors hover:bg-destructive/15 disabled:opacity-45"
[disabled]="!connected()"
(click)="leaveRequested.emit()"
aria-label="Leave call"
title="Leave call"
>
<ng-icon
name="lucidePhoneOff"
class="h-5 w-5"
/>
</button>
</div>

View File

@@ -0,0 +1,43 @@
import { Component, input, output } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMic,
lucideMicOff,
lucideMonitor,
lucideMonitorOff,
lucidePhone,
lucidePhoneOff,
lucideVideo,
lucideVideoOff
} from '@ng-icons/lucide';
@Component({
selector: 'app-private-call-controls',
standalone: true,
imports: [NgIcon],
viewProviders: [
provideIcons({
lucideMic,
lucideMicOff,
lucideMonitor,
lucideMonitorOff,
lucidePhone,
lucidePhoneOff,
lucideVideo,
lucideVideoOff
})
],
templateUrl: './private-call-controls.component.html'
})
export class PrivateCallControlsComponent {
readonly connected = input.required<boolean>();
readonly muted = input.required<boolean>();
readonly cameraEnabled = input.required<boolean>();
readonly screenSharing = input.required<boolean>();
readonly joinRequested = output<void>();
readonly muteToggled = output<void>();
readonly cameraToggled = output<void>();
readonly screenShareToggled = output<void>();
readonly leaveRequested = output<void>();
}

View File

@@ -0,0 +1,102 @@
<article
class="flex aspect-square min-w-0 flex-col items-center justify-center overflow-hidden rounded-2xl border border-border/80 bg-card/80 text-center shadow-sm backdrop-blur"
[class.w-[11rem]]="compact()"
[class.shrink-0]="compact()"
[class.p-4]="compact()"
[class.sm:w-[12.5rem]]="compact()"
[class.w-full]="!compact()"
[class.p-[clamp(1rem,4vw,1.5rem)]]="!compact()"
>
<div
class="relative h-[var(--participant-avatar-size)] w-[var(--participant-avatar-size)] rounded-full ring-2 transition-all duration-150 sm:h-[var(--participant-avatar-size-sm)] sm:w-[var(--participant-avatar-size-sm)]"
[attr.data-testid]="'call-participant-' + (user().oderId || user().id)"
[style.--participant-avatar-size]="avatarSize()"
[style.--participant-avatar-size-sm]="avatarSizeSm()"
[class.p-1.5]="compact()"
[class.p-2]="!compact()"
[class.ring-emerald-400]="speaking()"
[class.shadow-[0_0_0_6px_rgba(16,185,129,0.12)]]="speaking() && compact()"
[class.shadow-[0_0_0_8px_rgba(16,185,129,0.12)]]="speaking() && !compact()"
[class.ring-border]="!speaking()"
[class.opacity-55]="!connected()"
>
@if (user().avatarUrl) {
<img
[src]="user().avatarUrl"
[alt]="user().displayName"
[width]="compact() ? 96 : 160"
[height]="compact() ? 96 : 160"
decoding="async"
loading="lazy"
class="block h-full w-full rounded-full object-cover"
/>
} @else {
<div
class="grid h-full w-full place-items-center rounded-full bg-primary/15 font-semibold text-primary"
[class.text-3xl]="compact()"
[class.text-[clamp(1.75rem,8vw,3.5rem)]]="!compact()"
>
{{ participantInitial() }}
</div>
}
@if (!connected()) {
<div
class="absolute grid place-items-center rounded-full bg-background/72 backdrop-blur-[1px]"
[class.inset-1.5]="compact()"
[class.inset-2]="!compact()"
>
<div
class="grid place-items-center rounded-full border border-border bg-card text-muted-foreground shadow-sm"
[class.h-10]="compact()"
[class.w-10]="compact()"
[class.h-14]="!compact()"
[class.w-14]="!compact()"
>
<ng-icon
name="lucideWifiOff"
[class.h-5]="compact()"
[class.w-5]="compact()"
[class.h-7]="!compact()"
[class.w-7]="!compact()"
/>
</div>
</div>
}
@if (connected()) {
<span
class="absolute rounded-full border-card"
[class.bottom-3]="compact()"
[class.right-3]="compact()"
[class.h-4]="compact()"
[class.w-4]="compact()"
[class.border-[3px]]="compact()"
[class.bottom-5]="!compact()"
[class.right-5]="!compact()"
[class.h-5]="!compact()"
[class.w-5]="!compact()"
[class.border-4]="!compact()"
[class.bg-emerald-400]="speaking()"
[class.bg-muted-foreground]="!speaking()"
></span>
}
</div>
<div
class="min-w-0 max-w-full"
[class.mt-3]="compact()"
[class.mt-5]="!compact()"
>
<h2
class="truncate font-semibold text-foreground"
[class.text-sm]="compact()"
[class.text-[clamp(1rem,4vw,1.25rem)]]="!compact()"
>
{{ user().displayName }}
</h2>
@if (issueLabel(); as label) {
<p class="mt-1 text-xs font-semibold text-muted-foreground">{{ label }}</p>
}
</div>
</article>

View File

@@ -0,0 +1,33 @@
import { Component, input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideWifiOff } from '@ng-icons/lucide';
import type { User } from '../../shared-kernel';
@Component({
selector: 'app-private-call-participant-card',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucideWifiOff })],
host: { class: 'block min-w-0' },
templateUrl: './private-call-participant-card.component.html'
})
export class PrivateCallParticipantCardComponent {
readonly user = input.required<User>();
readonly connected = input.required<boolean>();
readonly speaking = input.required<boolean>();
readonly issueLabel = input<string | null>(null);
readonly compact = input(false);
avatarSize(): string {
return this.compact() ? '5rem' : 'clamp(4.25rem, 22vw, 10rem)';
}
avatarSizeSm(): string {
return this.compact() ? '6rem' : this.avatarSize();
}
participantInitial(): string {
return this.user().displayName.charAt(0).toUpperCase() || '?';
}
}

View File

@@ -0,0 +1,202 @@
<section
class="grid h-full min-h-0 bg-background lg:grid-cols-[minmax(0,1fr)_var(--private-call-chat-width)]"
[style.--private-call-chat-width]="chatWidthPx() + 'px'"
>
<main class="flex min-h-0 min-w-0 flex-col overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.10),transparent_34rem)]">
<header class="flex min-h-16 shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-5 backdrop-blur">
<div class="flex min-w-0 items-center gap-3">
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-emerald-500/10 text-emerald-500">
<ng-icon
name="lucidePhone"
class="h-5 w-5"
/>
</div>
<div class="min-w-0">
<h1 class="truncate text-base font-semibold text-foreground">Private Call</h1>
<p class="truncate text-xs text-muted-foreground">
@if (session()) {
{{ participantUsers().length }} participants
} @else {
Call not found
}
</p>
</div>
</div>
@if (session()) {
<div class="flex items-center gap-2">
<select
class="h-9 max-w-44 rounded-md border border-border bg-secondary px-2 text-sm text-foreground"
[ngModel]="inviteUserId()"
(ngModelChange)="inviteUserId.set($event)"
aria-label="Add user to call"
>
<option value="">Add user</option>
@for (user of inviteCandidates(); track userKey(user)) {
<option [value]="userKey(user)">{{ user.displayName }}</option>
}
</select>
<button
type="button"
class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-50"
[disabled]="!inviteUserId()"
(click)="inviteSelectedUser()"
aria-label="Add user"
title="Add user"
>
<ng-icon
name="lucideUserPlus"
class="h-4 w-4"
/>
</button>
</div>
}
</header>
@if (session()) {
<div class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-4 sm:px-5">
<div class="relative min-h-0 flex-1 overflow-hidden rounded-2xl border border-border/80 bg-card/45 shadow-sm">
@if (activeShares().length > 0) {
@if (focusedShare()) {
@if (hasMultipleShares()) {
<div class="absolute right-3 top-3 z-10 sm:right-4 sm:top-4">
<button
type="button"
data-testid="private-call-show-all-streams"
class="inline-flex h-10 items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 text-xs font-medium text-white/80 backdrop-blur transition hover:bg-black/65 hover:text-white"
title="Show all streams"
(click)="showAllStreams()"
>
<ng-icon
name="lucideUsers"
class="h-3.5 w-3.5"
/>
All streams
</button>
</div>
}
<app-voice-workspace-stream-tile
[item]="focusedShare()!"
[featured]="true"
[focused]="true"
data-testid="private-call-focused-stream"
[immersive]="true"
(focusRequested)="focusShare($event)"
/>
} @else if (hasMultipleShares()) {
<div
class="grid h-full min-h-0 auto-rows-[minmax(12rem,1fr)] grid-cols-1 gap-3 p-3 sm:grid-cols-2 sm:gap-4 sm:p-4"
[ngClass]="{ '2xl:grid-cols-3': activeShares().length > 2 }"
data-testid="private-call-stream-grid"
>
@for (share of activeShares(); track share.id) {
<div class="min-h-0 overflow-hidden rounded-2xl bg-black">
<app-voice-workspace-stream-tile
[item]="share"
[focused]="false"
(focusRequested)="focusShare($event)"
/>
</div>
}
</div>
}
} @else {
<div class="flex h-full min-h-0 items-center justify-center p-4 sm:p-6">
<div
class="grid w-full max-w-5xl grid-cols-[repeat(auto-fit,minmax(min(10rem,100%),1fr))] items-stretch justify-center gap-3 sm:grid-cols-[repeat(auto-fit,minmax(min(13rem,100%),1fr))] sm:gap-5 lg:gap-7"
>
<app-private-call-participant-card
*ngFor="let user of participantUsers(); trackBy: trackUserKey"
[user]="user"
[connected]="isParticipantConnected(user)"
[speaking]="isSpeaking(user)"
[issueLabel]="participantIssueLabel(user)"
></app-private-call-participant-card>
</div>
</div>
}
</div>
@if (activeShares().length > 0) {
<div class="shrink-0 pt-4">
<div class="flex w-full items-stretch gap-3 overflow-x-auto pb-1">
<app-private-call-participant-card
*ngFor="let user of participantUsers(); trackBy: trackUserKey"
[user]="user"
[connected]="isParticipantConnected(user)"
[speaking]="isSpeaking(user)"
[issueLabel]="participantIssueLabel(user)"
[compact]="true"
></app-private-call-participant-card>
@if (hasMultipleShares()) {
@for (share of focusedShare() ? thumbnailShares() : activeShares(); track share.id) {
<article
class="flex min-h-[8.75rem] w-[11rem] shrink-0 flex-col overflow-hidden rounded-2xl border border-border/80 bg-black shadow-sm sm:w-[12.5rem]"
>
<div class="min-h-0 flex-1">
<app-voice-workspace-stream-tile
[item]="share"
[mini]="true"
[focused]="false"
(focusRequested)="focusShare($event)"
/>
</div>
<div class="shrink-0 bg-black/80 px-3 py-2 text-xs font-semibold text-white/75">
{{ streamLabel(share) }}
</div>
</article>
}
}
</div>
</div>
}
<div class="shrink-0 pt-3">
<app-private-call-controls
class="mx-auto block w-full max-w-5xl"
[connected]="isConnected()"
[muted]="isMuted()"
[cameraEnabled]="isCameraEnabled()"
[screenSharing]="isScreenSharing()"
(joinRequested)="join()"
(muteToggled)="toggleMute()"
(cameraToggled)="toggleCamera()"
(screenShareToggled)="toggleScreenShare()"
(leaveRequested)="leave()"
></app-private-call-controls>
</div>
</div>
} @else {
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">No active call for this route.</div>
}
</main>
<aside class="relative hidden min-h-0 border-l border-border bg-card lg:block">
<div
class="group absolute inset-y-0 left-0 z-10 w-3 -translate-x-1/2 cursor-col-resize bg-transparent"
role="separator"
aria-orientation="vertical"
title="Resize chat"
data-testid="private-call-chat-resizer"
(mousedown)="startChatResize($event)"
>
<div class="mx-auto h-full w-px bg-border transition group-hover:bg-primary"></div>
</div>
<app-dm-chat
[conversationId]="session()?.conversationId ?? null"
[showCallButton]="false"
/>
</aside>
</section>
@if (showScreenShareQualityDialog()) {
<app-screen-share-quality-dialog
[selectedQuality]="screenShareQuality()"
[includeSystemAudio]="includeSystemAudio()"
(cancelled)="onScreenShareQualityCancelled()"
(confirmed)="onScreenShareQualityConfirmed($event)"
/>
}

View File

@@ -0,0 +1,564 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
DestroyRef,
HostListener,
computed,
effect,
inject,
signal,
untracked
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucidePhone,
lucideUsers,
lucideUserPlus
} from '@ng-icons/lucide';
import { map } from 'rxjs';
import {
DirectCallService,
participantToUser,
type DirectCallSession
} from '../../domains/direct-call';
import { DmChatComponent } from '../../domains/direct-message/feature/dm-chat/dm-chat.component';
import {
VoiceActivityService,
VoiceConnectionFacade,
VoicePlaybackService
} from '../../domains/voice-connection';
import {
ScreenShareFacade,
ScreenShareQuality,
ScreenShareStartOptions
} from '../../domains/screen-share';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session';
import { ScreenShareQualityDialogComponent } from '../../shared';
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
import { UsersActions } from '../../store/users/users.actions';
import { User } from '../../shared-kernel';
import { VoiceWorkspaceStreamItem } from '../room/voice-workspace/voice-workspace.models';
import { VoiceWorkspaceStreamTileComponent } from '../room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component';
import { PrivateCallControlsComponent } from './private-call-controls.component';
import { PrivateCallParticipantCardComponent } from './private-call-participant-card.component';
@Component({
selector: 'app-private-call',
standalone: true,
imports: [
CommonModule,
DmChatComponent,
FormsModule,
NgIcon,
PrivateCallControlsComponent,
PrivateCallParticipantCardComponent,
ScreenShareQualityDialogComponent,
VoiceWorkspaceStreamTileComponent
],
viewProviders: [
provideIcons({
lucidePhone,
lucideUsers,
lucideUserPlus
})
],
templateUrl: './private-call.component.html'
})
export class PrivateCallComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private readonly store = inject(Store);
private readonly calls = inject(DirectCallService);
private readonly voice = inject(VoiceConnectionFacade);
private readonly voiceActivity = inject(VoiceActivityService);
private readonly playback = inject(VoicePlaybackService);
private readonly screenShare = inject(ScreenShareFacade);
private chatResizing = false;
readonly allUsers = this.store.selectSignal(selectAllUsers);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly callId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), {
initialValue: this.route.snapshot.paramMap.get('callId')
});
readonly session = computed(() => this.calls.sessionById(this.callId()));
readonly participantUsers = computed(() => {
const session = this.session();
if (!session) {
return [] as User[];
}
return session.participantIds
.map((participantId) => this.userForSessionParticipant(session, participantId))
.filter((user): user is User => !!user);
});
readonly isConnected = computed(() => {
const session = this.session();
const currentUserId = this.currentUserKey();
return !!session && !!currentUserId && !!session.participants[currentUserId]?.joined;
});
readonly isMuted = this.voice.isMuted;
readonly isDeafened = this.voice.isDeafened;
readonly isCameraEnabled = this.voice.isCameraEnabled;
readonly isScreenSharing = this.screenShare.isScreenSharing;
readonly remoteStreamRevision = signal(0);
readonly includeSystemAudio = signal(false);
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
readonly askScreenShareQuality = signal(true);
readonly showScreenShareQualityDialog = signal(false);
readonly inviteUserId = signal('');
readonly focusedStreamId = signal<string | null>(null);
readonly showAllStreamsMode = signal(false);
readonly chatWidthPx = signal(384);
readonly inviteCandidates = computed(() => {
const participantIds = new Set(this.session()?.participantIds ?? []);
const currentUserId = this.currentUserKey();
return this.allUsers().filter((user) => {
const userId = this.userKey(user);
return userId !== currentUserId && !participantIds.has(userId);
});
});
readonly activeShares = computed<VoiceWorkspaceStreamItem[]>(() => {
this.remoteStreamRevision();
const shares: VoiceWorkspaceStreamItem[] = [];
const localUser = this.currentUser();
const localPeerKey = localUser ? this.userKey(localUser) : null;
const isJoinedToCurrentCall = this.isConnected();
const localScreenStream = isJoinedToCurrentCall ? this.screenShare.screenStream() : null;
const localCameraStream = isJoinedToCurrentCall && this.voice.isCameraEnabled() ? this.voice.getLocalCameraStream() : null;
if (localUser && localPeerKey && localScreenStream) {
shares.push(this.buildShare(localPeerKey, localUser, localScreenStream, true, 'screen'));
}
if (localUser && localPeerKey && localCameraStream) {
shares.push(this.buildShare(localPeerKey, localUser, localCameraStream, true, 'camera'));
}
for (const user of this.participantUsers()) {
const peerKey = this.getPeerKeyCandidates(user).find(
(candidate) => candidate !== localPeerKey
&& (
!!this.screenShare.getRemoteScreenShareStream(candidate)
|| !!this.voice.getRemoteCameraStream(candidate)
)
) ?? this.userKey(user);
if (peerKey === localPeerKey) {
continue;
}
const screenStream = this.screenShare.getRemoteScreenShareStream(peerKey);
const cameraStream = this.voice.getRemoteCameraStream(peerKey);
if (screenStream && this.hasActiveVideo(screenStream)) {
shares.push(this.buildShare(peerKey, user, screenStream, false, 'screen'));
}
if (cameraStream && this.hasActiveVideo(cameraStream)) {
shares.push(this.buildShare(peerKey, user, cameraStream, false, 'camera'));
}
}
return shares;
});
readonly featuredShare = computed(() => this.activeShares()[0] ?? null);
readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
readonly focusedShareId = computed(() => {
const requested = this.focusedStreamId();
const activeShares = this.activeShares();
if (this.showAllStreamsMode() && activeShares.length > 1) {
return null;
}
if (requested && activeShares.some((share) => share.id === requested)) {
return requested;
}
if (activeShares.length === 1) {
return activeShares[0].id;
}
return null;
});
readonly focusedShare = computed(
() => this.activeShares().find((share) => share.id === this.focusedShareId()) ?? null
);
readonly thumbnailShares = computed(() => {
const focusedShareId = this.focusedShareId();
if (!focusedShareId) {
return [] as VoiceWorkspaceStreamItem[];
}
return this.activeShares().filter((share) => share.id !== focusedShareId);
});
constructor() {
effect(() => {
const callId = this.callId();
if (callId) {
untracked(() => void this.calls.openCall(callId));
}
});
effect(() => {
const session = this.session();
if (session && !this.calls.hasOngoingActivity(session)) {
untracked(() => void this.router.navigate(['/dm', session.conversationId], { replaceUrl: true }));
}
});
effect(() => {
const session = this.session();
const currentUserId = this.currentUserKey();
const peerIds = (session ? this.remoteParticipantPeerIds(session, currentUserId) : []);
this.screenShare.syncRemoteScreenShareRequests(peerIds, this.isConnected() && !!session && session.status === 'connected');
});
effect(() => {
this.session();
if (this.isConnected()) {
this.trackLocalMic();
return;
}
this.untrackLocalMic();
});
this.screenShare.onRemoteStream
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.bumpRemoteStreamRevision());
this.screenShare.onPeerDisconnected
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.bumpRemoteStreamRevision());
this.destroyRef.onDestroy(() => {
this.screenShare.syncRemoteScreenShareRequests([], false);
});
}
@HostListener('window:mousemove', ['$event'])
onWindowMouseMove(event: MouseEvent): void {
if (!this.chatResizing) {
return;
}
event.preventDefault();
this.chatWidthPx.set(this.clampChatWidth(window.innerWidth - event.clientX));
}
@HostListener('window:mouseup')
onWindowMouseUp(): void {
this.chatResizing = false;
}
async join(): Promise<void> {
const session = this.session();
if (session) {
await this.calls.joinCall(session.callId);
}
}
leave(): void {
const session = this.session();
if (!session) {
return;
}
this.calls.leaveCall(session.callId);
this.untrackLocalMic();
void this.router.navigate(['/dm', session.conversationId]);
}
toggleMute(): void {
this.voice.toggleMute(!this.isMuted());
this.broadcastLocalVoiceState();
}
toggleDeafen(): void {
const nextDeafened = !this.isDeafened();
this.voice.toggleDeafen(nextDeafened);
this.playback.updateDeafened(nextDeafened);
if (nextDeafened && !this.isMuted()) {
this.voice.toggleMute(true);
}
this.broadcastLocalVoiceState();
}
async toggleCamera(): Promise<void> {
const user = this.currentUser();
if (!this.isConnected() || !user?.id) {
return;
}
if (this.isCameraEnabled()) {
this.voice.disableCamera();
this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: false } }));
this.bumpRemoteStreamRevision();
return;
}
await this.voice.enableCamera();
this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: true } }));
this.bumpRemoteStreamRevision();
}
async toggleScreenShare(): Promise<void> {
if (this.isScreenSharing()) {
this.screenShare.stopScreenShare();
this.bumpRemoteStreamRevision();
return;
}
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);
}
inviteSelectedUser(): void {
const callId = this.callId();
const userId = this.inviteUserId();
const user = this.allUsers().find((candidate) => this.userKey(candidate) === userId);
if (!callId || !user) {
return;
}
void this.calls.inviteUser(callId, user);
this.inviteUserId.set('');
}
isSpeaking(user: User): boolean {
return this.voiceActivity.isSpeaking(this.userKey(user))();
}
isParticipantConnected(user: User): boolean {
const session = this.session();
const userId = this.userKey(user);
if (!session) {
return false;
}
return !!session.participants[userId]?.joined
|| !!(
user.voiceState?.isConnected
&& user.voiceState.roomId === session.callId
&& user.voiceState.serverId === session.callId
);
}
participantIssueLabel(user: User): string | null {
return this.isParticipantConnected(user) ? null : 'Waiting';
}
streamLabel(share: VoiceWorkspaceStreamItem): string {
if (!share.isLocal) {
return share.user.displayName;
}
return share.kind === 'camera' ? 'Your camera' : 'Your screen';
}
focusShare(shareId: string): void {
this.showAllStreamsMode.set(false);
this.focusedStreamId.set(shareId);
}
showAllStreams(): void {
this.showAllStreamsMode.set(true);
this.focusedStreamId.set(null);
}
startChatResize(event: MouseEvent): void {
if (event.button !== 0) {
return;
}
event.preventDefault();
this.chatResizing = true;
}
userKey(user: User): string {
return user.oderId || user.id;
}
readonly trackUserKey = (index: number, user: User): string => this.userKey(user);
private currentUserKey(): string {
const user = this.currentUser();
return user ? this.userKey(user) : '';
}
private broadcastLocalVoiceState(): void {
const session = this.session();
const user = this.currentUser();
if (!session || !user?.id) {
return;
}
this.store.dispatch(UsersActions.updateVoiceState({
userId: user.id,
voiceState: {
isConnected: this.isConnected(),
isMuted: this.isMuted(),
isDeafened: this.isDeafened(),
roomId: session.callId,
serverId: session.callId
}
}));
}
private remoteParticipantPeerIds(session: DirectCallSession, currentUserId: string): string[] {
const peerIds = new Set<string>();
for (const participantId of session.participantIds) {
if (participantId === currentUserId) {
continue;
}
const user = this.userForSessionParticipant(session, participantId);
for (const peerId of [participantId, ...this.getPeerKeyCandidates(user)]) {
if (peerId && peerId !== currentUserId) {
peerIds.add(peerId);
}
}
}
return Array.from(peerIds);
}
private clampChatWidth(width: number): number {
const maxWidth = Math.min(640, Math.max(360, window.innerWidth - 560));
return Math.round(Math.max(320, Math.min(maxWidth, width)));
}
private getPeerKeyCandidates(user: User | null | undefined): string[] {
if (!user) {
return [];
}
return [
user.oderId,
user.peerId,
user.id
].filter((peerId, index, peerIds): peerId is string => !!peerId && peerIds.indexOf(peerId) === index);
}
private userForSessionParticipant(session: DirectCallSession, participantId: string): User | null {
const knownUser = this.calls.userForParticipant(participantId);
if (knownUser) {
return knownUser;
}
const participant = session.participants[participantId]?.profile;
return participant ? participantToUser(participant) : null;
}
private trackLocalMic(): void {
const userId = this.currentUserKey();
const stream = this.voice.getRawMicStream() ?? this.voice.getLocalStream();
if (userId && stream) {
this.voiceActivity.trackLocalMic(userId, stream);
}
}
private untrackLocalMic(): void {
const userId = this.currentUserKey();
if (userId) {
this.voiceActivity.untrackLocalMic(userId);
}
}
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> {
const options: ScreenShareStartOptions = {
includeSystemAudio: this.includeSystemAudio(),
quality
};
try {
await this.screenShare.startScreenShare(options);
this.bumpRemoteStreamRevision();
} catch {}
}
private buildShare(
peerKey: string,
user: User,
stream: MediaStream,
isLocal: boolean,
kind: VoiceWorkspaceStreamItem['kind']
): VoiceWorkspaceStreamItem {
return {
id: `${kind}:${peerKey}`,
peerKey,
user,
stream,
isLocal,
kind,
hasAudio: stream.getAudioTracks().some((track) => track.readyState === 'live')
};
}
private hasActiveVideo(stream: MediaStream): boolean {
return stream.getVideoTracks().some((track) => track.readyState === 'live');
}
private bumpRemoteStreamRevision(): void {
this.remoteStreamRevision.update((value) => value + 1);
}
}