614 lines
18 KiB
TypeScript
614 lines
18 KiB
TypeScript
/* eslint-disable @typescript-eslint/member-ordering */
|
|
import {
|
|
CUSTOM_ELEMENTS_SCHEMA,
|
|
Component,
|
|
DestroyRef,
|
|
HostListener,
|
|
computed,
|
|
effect,
|
|
inject,
|
|
input,
|
|
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,
|
|
lucideX,
|
|
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 { ViewportService } from '../../core/platform';
|
|
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
|
|
],
|
|
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
host: { class: 'block h-full w-full' },
|
|
viewProviders: [
|
|
provideIcons({
|
|
lucidePhone,
|
|
lucideX,
|
|
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 readonly viewport = inject(ViewportService);
|
|
private chatResizing = false;
|
|
|
|
readonly allUsers = this.store.selectSignal(selectAllUsers);
|
|
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
readonly isMobile = this.viewport.isMobile;
|
|
readonly callIdInput = input<string | null>(null);
|
|
readonly overlayMode = input(false);
|
|
readonly routeCallId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), {
|
|
initialValue: this.route.snapshot.paramMap.get('callId')
|
|
});
|
|
readonly callId = computed(() => this.callIdInput() ?? this.routeCallId());
|
|
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)) {
|
|
if (this.overlayMode()) {
|
|
untracked(() => this.calls.closeMobileCallOverlay());
|
|
return;
|
|
}
|
|
|
|
untracked(() => void this.router.navigate(['/dm', session.conversationId], { replaceUrl: true }));
|
|
}
|
|
});
|
|
|
|
effect(() => {
|
|
const callId = this.callId();
|
|
const session = this.session();
|
|
|
|
if (callId && session?.conversationId && this.isMobile() && !this.overlayMode()) {
|
|
untracked(() => {
|
|
void this.calls.openMobileCallOverlay(callId);
|
|
void this.router.navigate(['/pm', 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.calls.closeMobileCallOverlay();
|
|
this.untrackLocalMic();
|
|
|
|
if (!this.overlayMode()) {
|
|
void this.router.navigate(['/pm', session.conversationId]);
|
|
}
|
|
}
|
|
|
|
minimizeCall(): void {
|
|
const session = this.session();
|
|
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
if (this.overlayMode()) {
|
|
this.calls.closeMobileCallOverlay();
|
|
return;
|
|
}
|
|
|
|
void this.router.navigate(['/pm', session.conversationId]);
|
|
}
|
|
|
|
onMobileCallSlideChange(event: Event): void {
|
|
const detail = (event as CustomEvent).detail;
|
|
const swiper = Array.isArray(detail) ? detail[0] : detail;
|
|
|
|
if (this.isMobile() && swiper?.activeIndex === 0) {
|
|
this.minimizeCall();
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|