Fix private calls
This commit is contained in:
@@ -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>
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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() || '?';
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
/>
|
||||
}
|
||||
564
toju-app/src/app/features/direct-call/private-call.component.ts
Normal file
564
toju-app/src/app/features/direct-call/private-call.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from '../../../domains/voice-connection';
|
||||
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
import { DirectMessageService } from '../../../domains/direct-message';
|
||||
import { DirectCallService } from '../../../domains/direct-call';
|
||||
import { VoicePlaybackService } from '../../../domains/voice-connection';
|
||||
import { formatGameActivityElapsed } from '../../../domains/game-activity';
|
||||
import { ExternalLinkService } from '../../../core/platform/external-link.service';
|
||||
@@ -122,6 +123,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
private voiceSessionService = inject(VoiceSessionFacade);
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private directCalls = inject(DirectCallService);
|
||||
private profileCard = inject(ProfileCardService);
|
||||
private directMessages = inject(DirectMessageService);
|
||||
private readonly externalLinks = inject(ExternalLinkService);
|
||||
@@ -623,31 +625,12 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
|
||||
}
|
||||
|
||||
private prepareCrossServerVoiceJoin(room: Room, current: User | null): boolean {
|
||||
private prepareVoiceJoin(room: Room, current: User | null): void {
|
||||
if (!current?.voiceState?.isConnected || current.voiceState.serverId === room.id) {
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.voiceConnection.isVoiceConnected()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
this.disconnectCurrentVoiceTarget(current);
|
||||
}
|
||||
|
||||
private enableVoiceForJoin(room: Room, current: User | null, roomId: string): Promise<void> {
|
||||
@@ -675,10 +658,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.prepareCrossServerVoiceJoin(room, current ?? null)) {
|
||||
this.voiceConnection.reportConnectionError('Disconnect from the current voice server before joining a different server.');
|
||||
return;
|
||||
}
|
||||
this.directCalls.leaveCurrentJoinedCall();
|
||||
this.prepareVoiceJoin(room, current ?? null);
|
||||
|
||||
this.enableVoiceForJoin(room, current ?? null, roomId)
|
||||
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
||||
@@ -775,10 +756,14 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId))
|
||||
return;
|
||||
|
||||
this.disconnectCurrentVoiceTarget(current);
|
||||
}
|
||||
|
||||
private disconnectCurrentVoiceTarget(current: User | null): void {
|
||||
const previousVoiceState = current?.voiceState;
|
||||
|
||||
this.voiceConnection.stopVoiceHeartbeat();
|
||||
|
||||
this.untrackCurrentUserMic();
|
||||
|
||||
this.voiceConnection.disableVoice();
|
||||
|
||||
if (current?.id) {
|
||||
@@ -811,8 +796,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
roomId: previousVoiceState?.roomId,
|
||||
serverId: previousVoiceState?.serverId
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
canToggleFullscreen(): boolean {
|
||||
return !this.mini() && !this.compact() && (this.immersive() || this.focused());
|
||||
return !this.mini() && !this.compact();
|
||||
}
|
||||
|
||||
onTilePointerMove(): void {
|
||||
|
||||
@@ -17,6 +17,56 @@
|
||||
<ng-container *ngComponentOutlet="dmRailComponent()" />
|
||||
}
|
||||
|
||||
@for (call of directCalls.visibleActiveSessions(); track call.callId + ':' + $index) {
|
||||
<div class="group/call relative flex w-full justify-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute left-0 top-1/2 w-[3px] -translate-y-1/2 rounded-r-full bg-emerald-500 transition-[height,opacity] duration-100"
|
||||
[ngClass]="isSelectedCall($index) ? 'h-5 opacity-100' : 'h-0 opacity-0 group-hover/call:h-2.5 group-hover/call:opacity-100'"
|
||||
></span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 grid h-10 w-10 place-items-center overflow-hidden rounded-xl transition-colors hover:rounded-lg"
|
||||
[ngClass]="
|
||||
callAvatarUrls(call).length > 0
|
||||
? 'bg-emerald-950 text-white shadow-sm hover:bg-emerald-900'
|
||||
: 'bg-emerald-500/15 text-emerald-600 hover:bg-emerald-500/25'
|
||||
"
|
||||
[attr.data-testid]="'server-rail-call-' + call.callId"
|
||||
title="Open private call"
|
||||
[attr.aria-current]="isSelectedCall($index) ? 'page' : null"
|
||||
(click)="openCall(call.callId)"
|
||||
>
|
||||
@let callAvatars = callAvatarUrls(call);
|
||||
@if (callAvatars.length > 0) {
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 bg-emerald-950"
|
||||
></span>
|
||||
|
||||
@for (avatarUrl of callAvatars; track avatarUrl) {
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 bg-cover bg-center opacity-65 mix-blend-screen saturate-125"
|
||||
[style.backgroundImage]="'url(' + avatarUrl + ')'"
|
||||
></span>
|
||||
}
|
||||
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 bg-gradient-to-br from-black/10 via-emerald-950/20 to-black/45"
|
||||
></span>
|
||||
}
|
||||
|
||||
<ng-icon
|
||||
name="lucidePhone"
|
||||
class="relative z-10 h-5 w-5 drop-shadow"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Saved servers icons -->
|
||||
<div
|
||||
appThemeNode="serversRailList"
|
||||
|
||||
@@ -14,7 +14,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucidePlus } from '@ng-icons/lucide';
|
||||
import { lucidePhone, lucidePlus } from '@ng-icons/lucide';
|
||||
import {
|
||||
EMPTY,
|
||||
Subject,
|
||||
@@ -35,6 +35,7 @@ import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users
|
||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||
import { DatabaseService } from '../../../infrastructure/persistence';
|
||||
import { NotificationsFacade } from '../../../domains/notifications';
|
||||
import { DirectCallService, DirectCallSession } from '../../../domains/direct-call';
|
||||
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
import { hasRoomBanForUser } from '../../../domains/access-control';
|
||||
@@ -57,7 +58,7 @@ import {
|
||||
ThemeNodeDirective,
|
||||
UserBarComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucidePlus })],
|
||||
viewProviders: [provideIcons({ lucidePhone, lucidePlus })],
|
||||
templateUrl: './servers-rail.component.html'
|
||||
})
|
||||
export class ServersRailComponent {
|
||||
@@ -66,6 +67,7 @@ export class ServersRailComponent {
|
||||
private voiceSession = inject(VoiceSessionFacade);
|
||||
private db = inject(DatabaseService);
|
||||
private notifications = inject(NotificationsFacade);
|
||||
readonly directCalls = inject(DirectCallService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private banLookupRequestVersion = 0;
|
||||
@@ -92,10 +94,44 @@ export class ServersRailComponent {
|
||||
isOnDirectMessage = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/dm/'))
|
||||
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/dm/') || navigationEvent.urlAfterRedirects.startsWith('/pm/'))
|
||||
),
|
||||
{ initialValue: this.router.url.startsWith('/dm/') }
|
||||
{ initialValue: this.router.url.startsWith('/dm/') || this.router.url.startsWith('/pm/') }
|
||||
);
|
||||
isOnCall = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/call/'))
|
||||
),
|
||||
{ initialValue: this.router.url.startsWith('/call/') }
|
||||
);
|
||||
currentCallId = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
map((navigationEvent) => this.callIdFromUrl(navigationEvent.urlAfterRedirects))
|
||||
),
|
||||
{ initialValue: this.callIdFromUrl(this.router.url) }
|
||||
);
|
||||
selectedCallIndex = computed(() => {
|
||||
const routeCallId = this.currentCallId();
|
||||
const visibleCalls = this.directCalls.visibleActiveSessions();
|
||||
|
||||
if (routeCallId) {
|
||||
const routeMatchIndex = visibleCalls.findIndex((call) => call.callId === routeCallId || call.conversationId === routeCallId);
|
||||
|
||||
if (routeMatchIndex >= 0) {
|
||||
return routeMatchIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const currentSession = this.directCalls.currentSession();
|
||||
|
||||
if (!currentSession) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return visibleCalls.findIndex((call) => call.callId === currentSession.callId);
|
||||
});
|
||||
bannedServerName = signal('');
|
||||
showBannedDialog = signal(false);
|
||||
showPasswordDialog = signal(false);
|
||||
@@ -203,6 +239,26 @@ export class ServersRailComponent {
|
||||
this.savedRoomJoinRequests.next({ room });
|
||||
}
|
||||
|
||||
openCall(callId: string): void {
|
||||
void this.router.navigate(['/call', callId]);
|
||||
}
|
||||
|
||||
isSelectedCall(callIndex: number): boolean {
|
||||
return this.selectedCallIndex() === callIndex;
|
||||
}
|
||||
|
||||
callAvatarUrls(call: DirectCallSession): string[] {
|
||||
if (call.participantIds.length <= 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.values(call.participants)
|
||||
.filter((participant) => participant.joined)
|
||||
.map((participant) => this.directCalls.userForParticipant(participant.userId)?.avatarUrl || participant.profile.avatarUrl)
|
||||
.filter((avatarUrl): avatarUrl is string => !!avatarUrl)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
closeBannedDialog(): void {
|
||||
this.showBannedDialog.set(false);
|
||||
this.bannedServerName.set('');
|
||||
@@ -229,6 +285,13 @@ export class ServersRailComponent {
|
||||
return !!this.bannedRoomLookup()[room.id];
|
||||
}
|
||||
|
||||
private callIdFromUrl(url: string): string | null {
|
||||
const path = url.split(/[?#]/, 1)[0];
|
||||
const match = path.match(/^\/call\/([^/]+)/);
|
||||
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
openContextMenu(evt: MouseEvent, room: Room): void {
|
||||
evt.preventDefault();
|
||||
this.contextRoom.set(room);
|
||||
@@ -311,7 +374,7 @@ export class ServersRailComponent {
|
||||
}
|
||||
|
||||
isSelectedRoom(room: Room): boolean {
|
||||
if (this.isOnDirectMessage()) {
|
||||
if (this.isOnDirectMessage() || this.isOnCall()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,40 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border bg-secondary/20 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Experimental VLC.js playback</p>
|
||||
@if (experimentalMedia.vlcJsRuntimeStatus() === 'checking') {
|
||||
<p class="text-xs text-muted-foreground">Checking for a bundled VLC.js runtime...</p>
|
||||
} @else if (experimentalMedia.vlcJsRuntimeAvailable()) {
|
||||
<p class="text-xs text-muted-foreground">Offer a manual player for unsupported downloaded audio and video files.</p>
|
||||
} @else {
|
||||
<p class="text-xs text-muted-foreground">No VLC.js runtime is bundled. Unsupported desktop media can be opened in the system player.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="relative inline-flex items-center"
|
||||
[class.cursor-pointer]="experimentalMedia.vlcJsRuntimeAvailable()"
|
||||
[class.cursor-not-allowed]="!experimentalMedia.vlcJsRuntimeAvailable()"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="experimentalMedia.vlcJsPlaybackEnabled()"
|
||||
[disabled]="!experimentalMedia.vlcJsRuntimeAvailable()"
|
||||
(change)="onExperimentalVlcPlaybackChange($event)"
|
||||
id="general-experimental-vlc-playback-toggle"
|
||||
aria-label="Toggle experimental VLC.js playback"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { DesktopSettingsSnapshot } from '../../../../core/platform/electron
|
||||
import { loadGeneralSettingsFromStorage, saveGeneralSettingsToStorage } from '../../../../infrastructure/persistence';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { PlatformService } from '../../../../core/platform';
|
||||
import { ExperimentalMediaSettingsService } from '../../../../domains/experimental-media/application/services/experimental-media-settings.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-general-settings',
|
||||
@@ -27,6 +28,7 @@ import { PlatformService } from '../../../../core/platform';
|
||||
export class GeneralSettingsComponent {
|
||||
private platform = inject(PlatformService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
readonly experimentalMedia = inject(ExperimentalMediaSettingsService);
|
||||
|
||||
readonly isElectron = this.platform.isElectron;
|
||||
reopenLastViewedChat = signal(true);
|
||||
@@ -98,6 +100,13 @@ export class GeneralSettingsComponent {
|
||||
}
|
||||
}
|
||||
|
||||
onExperimentalVlcPlaybackChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.experimentalMedia.setVlcJsPlaybackEnabled(!!input.checked);
|
||||
input.checked = this.experimentalMedia.vlcJsPlaybackEnabled();
|
||||
}
|
||||
|
||||
private async loadDesktopSettings(): Promise<void> {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user