All checks were successful
Queue Release Build / prepare (push) Successful in 1m6s
Deploy Web Apps / deploy (push) Successful in 7m35s
Queue Release Build / build-windows (push) Successful in 29m57s
Queue Release Build / build-linux (push) Successful in 46m28s
Queue Release Build / finalize (push) Successful in 49s
1042 lines
29 KiB
TypeScript
1042 lines
29 KiB
TypeScript
/* eslint-disable @typescript-eslint/member-ordering, complexity */
|
|
import { CommonModule } from '@angular/common';
|
|
import {
|
|
Component,
|
|
DestroyRef,
|
|
ElementRef,
|
|
HostListener,
|
|
computed,
|
|
effect,
|
|
inject,
|
|
signal,
|
|
viewChild
|
|
} from '@angular/core';
|
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
import { Store } from '@ngrx/store';
|
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
import {
|
|
lucideHeadphones,
|
|
lucideMaximize,
|
|
lucideMic,
|
|
lucideMicOff,
|
|
lucideMinimize,
|
|
lucideMonitor,
|
|
lucideMonitorOff,
|
|
lucidePhoneOff,
|
|
lucideUsers,
|
|
lucideVolume2,
|
|
lucideVolumeX,
|
|
lucideX
|
|
} from '@ng-icons/lucide';
|
|
|
|
import { User } from '../../../shared-kernel';
|
|
import {
|
|
loadVoiceSettingsFromStorage,
|
|
saveVoiceSettingsToStorage,
|
|
VoiceSessionFacade,
|
|
VoiceWorkspacePosition,
|
|
VoiceWorkspaceService
|
|
} from '../../../domains/voice-session';
|
|
import { VoiceConnectionFacade, VoicePlaybackService } from '../../../domains/voice-connection';
|
|
import {
|
|
ScreenShareFacade,
|
|
ScreenShareQuality,
|
|
ScreenShareStartOptions
|
|
} from '../../../domains/screen-share';
|
|
import { ViewportService } from '../../../core/platform';
|
|
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
|
import { UsersActions } from '../../../store/users/users.actions';
|
|
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
|
|
import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../shared';
|
|
import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.service';
|
|
import { VoiceWorkspaceStreamTileComponent } from './voice-workspace-stream-tile/voice-workspace-stream-tile.component';
|
|
import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
|
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
|
|
|
@Component({
|
|
selector: 'app-voice-workspace',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
NgIcon,
|
|
ScreenShareQualityDialogComponent,
|
|
VoiceWorkspaceStreamTileComponent,
|
|
UserAvatarComponent,
|
|
ThemeNodeDirective
|
|
],
|
|
viewProviders: [
|
|
provideIcons({
|
|
lucideHeadphones,
|
|
lucideMaximize,
|
|
lucideMic,
|
|
lucideMicOff,
|
|
lucideMinimize,
|
|
lucideMonitor,
|
|
lucideMonitorOff,
|
|
lucidePhoneOff,
|
|
lucideUsers,
|
|
lucideVolume2,
|
|
lucideVolumeX,
|
|
lucideX
|
|
})
|
|
],
|
|
templateUrl: './voice-workspace.component.html',
|
|
host: {
|
|
class: 'pointer-events-none absolute inset-0 z-20 block'
|
|
}
|
|
})
|
|
export class VoiceWorkspaceComponent {
|
|
private readonly destroyRef = inject(DestroyRef);
|
|
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
private readonly store = inject(Store);
|
|
private readonly webrtc = inject(VoiceConnectionFacade);
|
|
private readonly screenShare = inject(ScreenShareFacade);
|
|
private readonly viewport = inject(ViewportService);
|
|
readonly isMobile = this.viewport.isMobile;
|
|
private readonly voicePlayback = inject(VoicePlaybackService);
|
|
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
|
private readonly voiceSession = inject(VoiceSessionFacade);
|
|
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
|
|
|
|
private readonly remoteStreamRevision = signal(0);
|
|
|
|
private readonly miniWindowWidth = 320;
|
|
private readonly miniWindowHeight = 228;
|
|
private miniWindowDragging = false;
|
|
private miniDragOffsetX = 0;
|
|
private miniDragOffsetY = 0;
|
|
private wasExpanded = false;
|
|
private wasAutoHideChrome = false;
|
|
private headerHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
private readonly observedRemoteStreams = new Map<string, {
|
|
stream: MediaStream;
|
|
cleanup: () => void;
|
|
}>();
|
|
|
|
readonly miniPreviewRef = viewChild<ElementRef<HTMLVideoElement>>('miniPreview');
|
|
|
|
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
|
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
readonly onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
|
readonly voiceSessionInfo = this.voiceSession.voiceSession;
|
|
|
|
readonly showExpanded = this.voiceWorkspace.isExpanded;
|
|
readonly showMiniWindow = this.voiceWorkspace.isMinimized;
|
|
readonly shouldConnectRemoteShares = this.voiceWorkspace.shouldConnectRemoteShares;
|
|
readonly miniPosition = this.voiceWorkspace.miniWindowPosition;
|
|
readonly showWorkspaceHeader = signal(true);
|
|
|
|
readonly isConnected = computed(() => this.webrtc.isVoiceConnected());
|
|
readonly isMuted = computed(() => this.webrtc.isMuted());
|
|
readonly isDeafened = computed(() => this.webrtc.isDeafened());
|
|
readonly isScreenSharing = computed(() => this.screenShare.isScreenSharing());
|
|
|
|
readonly includeSystemAudio = signal(false);
|
|
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
|
|
readonly askScreenShareQuality = signal(true);
|
|
readonly showScreenShareQualityDialog = signal(false);
|
|
|
|
readonly connectedVoiceUsers = computed(() => {
|
|
const room = this.currentRoom();
|
|
const me = this.currentUser();
|
|
const roomId = me?.voiceState?.roomId;
|
|
const serverId = me?.voiceState?.serverId;
|
|
|
|
if (!room || !roomId || !serverId || serverId !== room.id) {
|
|
return [] as User[];
|
|
}
|
|
|
|
const voiceUsers = this.onlineUsers().filter(
|
|
(user) =>
|
|
!!user.voiceState?.isConnected
|
|
&& user.voiceState.roomId === roomId
|
|
&& user.voiceState.serverId === room.id
|
|
);
|
|
|
|
if (!me?.voiceState?.isConnected) {
|
|
return voiceUsers;
|
|
}
|
|
|
|
const currentKeys = new Set(voiceUsers.map((user) => user.oderId || user.id));
|
|
const meKey = me.oderId || me.id;
|
|
|
|
if (meKey && !currentKeys.has(meKey)) {
|
|
return [me, ...voiceUsers];
|
|
}
|
|
|
|
return voiceUsers;
|
|
});
|
|
|
|
readonly activeShares = computed<VoiceWorkspaceStreamItem[]>(() => {
|
|
this.remoteStreamRevision();
|
|
|
|
const room = this.currentRoom();
|
|
const me = this.currentUser();
|
|
const connectedRoomId = me?.voiceState?.roomId;
|
|
const connectedServerId = me?.voiceState?.serverId;
|
|
|
|
if (!room || !me || !connectedRoomId || connectedServerId !== room.id) {
|
|
return [];
|
|
}
|
|
|
|
const shares: VoiceWorkspaceStreamItem[] = [];
|
|
const localScreenStream = this.screenShare.screenStream();
|
|
const localCameraStream = this.webrtc.isCameraEnabled()
|
|
? this.webrtc.getLocalCameraStream()
|
|
: null;
|
|
const localPeerKey = this.getUserPeerKey(me);
|
|
|
|
if (localScreenStream && localPeerKey) {
|
|
shares.push({
|
|
id: this.buildStreamId(localPeerKey, 'screen'),
|
|
peerKey: localPeerKey,
|
|
user: me,
|
|
stream: localScreenStream,
|
|
isLocal: true,
|
|
kind: 'screen',
|
|
hasAudio: this.hasActiveAudio(localScreenStream)
|
|
});
|
|
}
|
|
|
|
if (localCameraStream && localPeerKey) {
|
|
shares.push({
|
|
id: this.buildStreamId(localPeerKey, 'camera'),
|
|
peerKey: localPeerKey,
|
|
user: me,
|
|
stream: localCameraStream,
|
|
isLocal: true,
|
|
kind: 'camera',
|
|
hasAudio: false
|
|
});
|
|
}
|
|
|
|
for (const user of this.onlineUsers()) {
|
|
const peerKey = this.getUserPeerKey(user);
|
|
|
|
if (!peerKey || peerKey === localPeerKey) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
!user.voiceState?.isConnected
|
|
|| user.voiceState.roomId !== connectedRoomId
|
|
|| user.voiceState.serverId !== room.id
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const remoteShare = user.screenShareState?.isSharing === false
|
|
? null
|
|
: this.getRemoteScreenShareStream(user);
|
|
|
|
if (remoteShare) {
|
|
shares.push({
|
|
id: this.buildStreamId(remoteShare.peerKey, 'screen'),
|
|
peerKey: remoteShare.peerKey,
|
|
user,
|
|
stream: remoteShare.stream,
|
|
isLocal: false,
|
|
kind: 'screen',
|
|
hasAudio: this.hasActiveAudio(remoteShare.stream)
|
|
});
|
|
}
|
|
|
|
const remoteCamera = user.cameraState?.isEnabled === false
|
|
? null
|
|
: this.getRemoteCameraStream(user);
|
|
|
|
if (remoteCamera) {
|
|
shares.push({
|
|
id: this.buildStreamId(remoteCamera.peerKey, 'camera'),
|
|
peerKey: remoteCamera.peerKey,
|
|
user,
|
|
stream: remoteCamera.stream,
|
|
isLocal: false,
|
|
kind: 'camera',
|
|
hasAudio: false
|
|
});
|
|
}
|
|
}
|
|
|
|
return shares.sort((shareA, shareB) => {
|
|
if (shareA.isLocal !== shareB.isLocal) {
|
|
return shareA.isLocal ? 1 : -1;
|
|
}
|
|
|
|
if (shareA.kind !== shareB.kind) {
|
|
return shareA.kind === 'screen' ? -1 : 1;
|
|
}
|
|
|
|
return shareA.user.displayName.localeCompare(shareB.user.displayName);
|
|
});
|
|
});
|
|
|
|
readonly widescreenShareId = computed(() => {
|
|
const requested = this.voiceWorkspace.focusedStreamId();
|
|
const activeShares = this.activeShares();
|
|
|
|
if (requested && activeShares.some((share) => share.id === requested)) {
|
|
return requested;
|
|
}
|
|
|
|
if (activeShares.length === 1) {
|
|
return activeShares[0].id;
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
readonly isWidescreenMode = computed(() => this.widescreenShareId() !== null);
|
|
readonly shouldAutoHideChrome = computed(
|
|
() => this.showExpanded() && this.isWidescreenMode() && this.activeShares().length > 0
|
|
);
|
|
readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
|
|
readonly widescreenShare = computed(
|
|
() => this.activeShares().find((share) => share.id === this.widescreenShareId()) ?? null
|
|
);
|
|
readonly focusedAudioShare = computed(() => {
|
|
const share = this.widescreenShare();
|
|
|
|
return share && !share.isLocal && share.hasAudio ? share : null;
|
|
});
|
|
readonly focusedShareTitle = computed(() => {
|
|
const share = this.widescreenShare();
|
|
|
|
if (!share) {
|
|
return 'Focused stream';
|
|
}
|
|
|
|
if (!share.isLocal) {
|
|
return share.user.displayName;
|
|
}
|
|
|
|
return share.kind === 'camera' ? 'Your camera' : 'Your screen';
|
|
});
|
|
readonly thumbnailShares = computed(() => {
|
|
const widescreenShareId = this.widescreenShareId();
|
|
|
|
if (!widescreenShareId) {
|
|
return [] as VoiceWorkspaceStreamItem[];
|
|
}
|
|
|
|
return this.activeShares().filter((share) => share.id !== widescreenShareId);
|
|
});
|
|
readonly miniPreviewShare = computed(
|
|
() => this.widescreenShare() ?? this.activeShares()[0] ?? null
|
|
);
|
|
readonly miniPreviewTitle = computed(() => {
|
|
const previewShare = this.miniPreviewShare();
|
|
|
|
if (!previewShare) {
|
|
return 'Voice workspace';
|
|
}
|
|
|
|
if (!previewShare.isLocal) {
|
|
return previewShare.user.displayName;
|
|
}
|
|
|
|
return previewShare.kind === 'camera' ? 'Your camera' : 'Your screen';
|
|
});
|
|
readonly liveShareCount = computed(() => this.activeShares().length);
|
|
readonly connectedVoiceChannelName = computed(() => {
|
|
const me = this.currentUser();
|
|
const room = this.currentRoom();
|
|
const channelId = me?.voiceState?.roomId ?? this.voiceSessionInfo()?.roomId;
|
|
const channel = room?.channels?.find(
|
|
(candidate) => candidate.id === channelId && candidate.type === 'voice'
|
|
);
|
|
|
|
if (channel) {
|
|
return channel.name;
|
|
}
|
|
|
|
const sessionRoomName = this.voiceSessionInfo()?.roomName?.replace(/^🔊\s*/, '');
|
|
|
|
return sessionRoomName || 'Voice Lounge';
|
|
});
|
|
readonly serverName = computed(
|
|
() => this.currentRoom()?.name || this.voiceSessionInfo()?.serverName || 'Voice server'
|
|
);
|
|
|
|
constructor() {
|
|
this.destroyRef.onDestroy(() => {
|
|
this.clearHeaderHideTimeout();
|
|
this.cleanupObservedRemoteStreams();
|
|
this.screenShare.syncRemoteScreenShareRequests([], false);
|
|
this.workspacePlayback.teardownAll();
|
|
});
|
|
|
|
this.screenShare.onRemoteStream
|
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
.subscribe(({ peerId }) => {
|
|
this.observeRemoteStream(peerId);
|
|
this.bumpRemoteStreamRevision();
|
|
});
|
|
|
|
this.screenShare.onPeerDisconnected
|
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
.subscribe(() => this.bumpRemoteStreamRevision());
|
|
|
|
effect(() => {
|
|
const ref = this.miniPreviewRef();
|
|
const previewShare = this.miniPreviewShare();
|
|
const showMiniWindow = this.showMiniWindow();
|
|
|
|
if (!ref) {
|
|
return;
|
|
}
|
|
|
|
const video = ref.nativeElement;
|
|
|
|
if (!showMiniWindow || !previewShare) {
|
|
video.srcObject = null;
|
|
return;
|
|
}
|
|
|
|
if (video.srcObject !== previewShare.stream) {
|
|
video.srcObject = previewShare.stream;
|
|
}
|
|
|
|
video.muted = true;
|
|
video.volume = 0;
|
|
void video.play().catch(() => {});
|
|
});
|
|
|
|
effect(() => {
|
|
if (!this.showMiniWindow()) {
|
|
return;
|
|
}
|
|
|
|
requestAnimationFrame(() => this.ensureMiniWindowPosition());
|
|
});
|
|
|
|
effect(() => {
|
|
const shouldConnectRemoteShares = this.shouldConnectRemoteShares();
|
|
const currentUserPeerKey = this.getUserPeerKey(this.currentUser());
|
|
const peerKeys = Array.from(new Set(
|
|
this.connectedVoiceUsers()
|
|
.map((user) => this.getUserPeerKey(user))
|
|
.filter((peerKey): peerKey is string => !!peerKey && peerKey !== currentUserPeerKey)
|
|
));
|
|
|
|
this.screenShare.syncRemoteScreenShareRequests(peerKeys, shouldConnectRemoteShares);
|
|
|
|
if (!shouldConnectRemoteShares) {
|
|
this.workspacePlayback.teardownAll();
|
|
}
|
|
});
|
|
|
|
effect(() => {
|
|
this.remoteStreamRevision();
|
|
|
|
const room = this.currentRoom();
|
|
const currentUser = this.currentUser();
|
|
const connectedRoomId = currentUser?.voiceState?.roomId;
|
|
const connectedServerId = currentUser?.voiceState?.serverId;
|
|
const peerKeys = new Set<string>();
|
|
|
|
if (room && connectedRoomId && connectedServerId === room.id) {
|
|
for (const user of this.onlineUsers()) {
|
|
if (
|
|
!user.voiceState?.isConnected
|
|
|| user.voiceState.roomId !== connectedRoomId
|
|
|| user.voiceState.serverId !== room.id
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
for (const peerKey of [user.oderId, user.id]) {
|
|
if (!peerKey || peerKey === this.getUserPeerKey(currentUser)) {
|
|
continue;
|
|
}
|
|
|
|
peerKeys.add(peerKey);
|
|
this.observeRemoteStream(peerKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.pruneObservedRemoteStreams(peerKeys);
|
|
});
|
|
|
|
effect(() => {
|
|
const isExpanded = this.showExpanded();
|
|
const shouldAutoHideChrome = this.shouldAutoHideChrome();
|
|
|
|
if (!isExpanded) {
|
|
this.clearHeaderHideTimeout();
|
|
this.showWorkspaceHeader.set(true);
|
|
this.wasExpanded = false;
|
|
this.wasAutoHideChrome = false;
|
|
return;
|
|
}
|
|
|
|
if (!shouldAutoHideChrome) {
|
|
this.clearHeaderHideTimeout();
|
|
this.showWorkspaceHeader.set(true);
|
|
this.wasExpanded = true;
|
|
this.wasAutoHideChrome = false;
|
|
return;
|
|
}
|
|
|
|
const shouldRevealChrome = !this.wasExpanded || !this.wasAutoHideChrome;
|
|
|
|
this.wasExpanded = true;
|
|
this.wasAutoHideChrome = true;
|
|
|
|
if (shouldRevealChrome) {
|
|
this.revealWorkspaceChrome();
|
|
}
|
|
});
|
|
}
|
|
|
|
onWorkspacePointerMove(): void {
|
|
if (!this.shouldAutoHideChrome()) {
|
|
return;
|
|
}
|
|
|
|
this.revealWorkspaceChrome();
|
|
}
|
|
|
|
@HostListener('window:mousemove', ['$event'])
|
|
onWindowMouseMove(event: MouseEvent): void {
|
|
if (!this.miniWindowDragging) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
const bounds = this.getWorkspaceBounds();
|
|
const nextPosition = this.clampMiniWindowPosition({
|
|
left: event.clientX - bounds.left - this.miniDragOffsetX,
|
|
top: event.clientY - bounds.top - this.miniDragOffsetY
|
|
});
|
|
|
|
this.voiceWorkspace.setMiniWindowPosition(nextPosition);
|
|
}
|
|
|
|
@HostListener('window:mouseup')
|
|
onWindowMouseUp(): void {
|
|
this.miniWindowDragging = false;
|
|
}
|
|
|
|
@HostListener('window:resize')
|
|
onWindowResize(): void {
|
|
if (!this.showMiniWindow()) {
|
|
return;
|
|
}
|
|
|
|
this.ensureMiniWindowPosition();
|
|
}
|
|
|
|
trackUser(index: number, user: User): string {
|
|
return this.getUserPeerKey(user) || `${index}`;
|
|
}
|
|
|
|
trackShare(index: number, share: VoiceWorkspaceStreamItem): string {
|
|
return share.id || `${index}`;
|
|
}
|
|
|
|
focusShare(peerKey: string): void {
|
|
if (this.widescreenShareId() === peerKey) {
|
|
return;
|
|
}
|
|
|
|
this.voiceWorkspace.focusStream(peerKey);
|
|
}
|
|
|
|
showAllStreams(): void {
|
|
this.voiceWorkspace.clearFocusedStream();
|
|
}
|
|
|
|
minimizeWorkspace(): void {
|
|
this.voiceWorkspace.minimize();
|
|
this.ensureMiniWindowPosition();
|
|
}
|
|
|
|
restoreWorkspace(): void {
|
|
this.voiceWorkspace.restore();
|
|
}
|
|
|
|
closeWorkspace(): void {
|
|
this.voiceWorkspace.clearFocusedStream();
|
|
this.voiceWorkspace.close();
|
|
}
|
|
|
|
focusedShareVolume(): number {
|
|
const share = this.focusedAudioShare();
|
|
|
|
if (!share) {
|
|
return 100;
|
|
}
|
|
|
|
return this.workspacePlayback.getUserVolume(share.peerKey);
|
|
}
|
|
|
|
focusedShareMuted(): boolean {
|
|
const share = this.focusedAudioShare();
|
|
|
|
if (!share) {
|
|
return false;
|
|
}
|
|
|
|
return this.workspacePlayback.isUserMuted(share.peerKey);
|
|
}
|
|
|
|
toggleFocusedShareMuted(): void {
|
|
const share = this.focusedAudioShare();
|
|
|
|
if (!share) {
|
|
return;
|
|
}
|
|
|
|
this.workspacePlayback.setUserMuted(
|
|
share.peerKey,
|
|
!this.workspacePlayback.isUserMuted(share.peerKey)
|
|
);
|
|
}
|
|
|
|
updateFocusedShareVolume(event: Event): void {
|
|
const share = this.focusedAudioShare();
|
|
|
|
if (!share) {
|
|
return;
|
|
}
|
|
|
|
const input = event.target as HTMLInputElement;
|
|
const nextVolume = Math.max(0, Math.min(100, parseInt(input.value, 10) || 0));
|
|
|
|
this.workspacePlayback.setUserVolume(share.peerKey, nextVolume);
|
|
|
|
if (nextVolume > 0 && this.workspacePlayback.isUserMuted(share.peerKey)) {
|
|
this.workspacePlayback.setUserMuted(share.peerKey, false);
|
|
}
|
|
}
|
|
|
|
startMiniWindowDrag(event: MouseEvent): void {
|
|
const target = event.target as HTMLElement | null;
|
|
|
|
if (target?.closest('button, input')) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
const bounds = this.getWorkspaceBounds();
|
|
const currentPosition = this.voiceWorkspace.miniWindowPosition();
|
|
|
|
this.miniWindowDragging = true;
|
|
this.miniDragOffsetX = event.clientX - bounds.left - currentPosition.left;
|
|
this.miniDragOffsetY = event.clientY - bounds.top - currentPosition.top;
|
|
}
|
|
|
|
toggleMute(): void {
|
|
const nextMuted = !this.isMuted();
|
|
|
|
this.webrtc.toggleMute(nextMuted);
|
|
this.syncVoiceState({
|
|
isConnected: this.isConnected(),
|
|
isMuted: nextMuted,
|
|
isDeafened: this.isDeafened()
|
|
});
|
|
|
|
this.broadcastVoiceState(nextMuted, this.isDeafened());
|
|
}
|
|
|
|
toggleDeafen(): void {
|
|
const nextDeafened = !this.isDeafened();
|
|
|
|
let nextMuted = this.isMuted();
|
|
|
|
this.webrtc.toggleDeafen(nextDeafened);
|
|
this.voicePlayback.updateDeafened(nextDeafened);
|
|
|
|
if (nextDeafened && !nextMuted) {
|
|
nextMuted = true;
|
|
this.webrtc.toggleMute(true);
|
|
}
|
|
|
|
this.syncVoiceState({
|
|
isConnected: this.isConnected(),
|
|
isMuted: nextMuted,
|
|
isDeafened: nextDeafened
|
|
});
|
|
|
|
this.broadcastVoiceState(nextMuted, nextDeafened);
|
|
}
|
|
|
|
async toggleScreenShare(): Promise<void> {
|
|
if (this.isScreenSharing()) {
|
|
this.screenShare.stopScreenShare();
|
|
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);
|
|
}
|
|
|
|
disconnect(): void {
|
|
this.webrtc.stopVoiceHeartbeat();
|
|
|
|
this.webrtc.broadcastMessage({
|
|
type: 'voice-state',
|
|
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
|
displayName: this.currentUser()?.displayName || 'User',
|
|
voiceState: {
|
|
isConnected: false,
|
|
isMuted: false,
|
|
isDeafened: false,
|
|
roomId: undefined,
|
|
serverId: undefined
|
|
}
|
|
});
|
|
|
|
if (this.isScreenSharing()) {
|
|
this.screenShare.stopScreenShare();
|
|
}
|
|
|
|
this.webrtc.disableVoice();
|
|
this.voicePlayback.teardownAll();
|
|
this.voicePlayback.updateDeafened(false);
|
|
|
|
const user = this.currentUser();
|
|
|
|
if (user?.id) {
|
|
this.store.dispatch(
|
|
UsersActions.updateVoiceState({
|
|
userId: user.id,
|
|
voiceState: {
|
|
isConnected: false,
|
|
isMuted: false,
|
|
isDeafened: false,
|
|
roomId: undefined,
|
|
serverId: undefined
|
|
}
|
|
})
|
|
);
|
|
|
|
this.store.dispatch(
|
|
UsersActions.updateCameraState({
|
|
userId: user.id,
|
|
cameraState: { isEnabled: false }
|
|
})
|
|
);
|
|
}
|
|
|
|
this.voiceSession.endSession();
|
|
this.voiceWorkspace.reset();
|
|
}
|
|
|
|
getControlButtonClass(
|
|
isActive: boolean,
|
|
accent: 'default' | 'primary' | 'danger' = 'default'
|
|
): string {
|
|
const base = 'inline-flex min-w-[5.5rem] flex-col items-center gap-2 rounded-2xl px-4 py-3 text-sm font-medium transition-colors';
|
|
|
|
if (accent === 'danger') {
|
|
return `${base} bg-destructive text-destructive-foreground hover:bg-destructive/90`;
|
|
}
|
|
|
|
if (accent === 'primary' || isActive) {
|
|
return `${base} bg-primary/15 text-primary hover:bg-primary/25`;
|
|
}
|
|
|
|
return `${base} bg-secondary/80 text-foreground hover:bg-secondary`;
|
|
}
|
|
|
|
private bumpRemoteStreamRevision(): void {
|
|
this.remoteStreamRevision.update((value) => value + 1);
|
|
}
|
|
|
|
private syncVoiceState(voiceState: {
|
|
isConnected: boolean;
|
|
isMuted: boolean;
|
|
isDeafened: boolean;
|
|
}): void {
|
|
const user = this.currentUser();
|
|
const identifiers = this.getCurrentVoiceIdentifiers();
|
|
|
|
if (!user?.id) {
|
|
return;
|
|
}
|
|
|
|
this.store.dispatch(
|
|
UsersActions.updateVoiceState({
|
|
userId: user.id,
|
|
voiceState: {
|
|
...voiceState,
|
|
roomId: identifiers.roomId,
|
|
serverId: identifiers.serverId
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
private broadcastVoiceState(isMuted: boolean, isDeafened: boolean): void {
|
|
const identifiers = this.getCurrentVoiceIdentifiers();
|
|
|
|
this.webrtc.broadcastMessage({
|
|
type: 'voice-state',
|
|
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
|
displayName: this.currentUser()?.displayName || 'User',
|
|
voiceState: {
|
|
isConnected: this.isConnected(),
|
|
isMuted,
|
|
isDeafened,
|
|
roomId: identifiers.roomId,
|
|
serverId: identifiers.serverId
|
|
}
|
|
});
|
|
}
|
|
|
|
private getCurrentVoiceIdentifiers(): {
|
|
roomId: string | undefined;
|
|
serverId: string | undefined;
|
|
} {
|
|
const me = this.currentUser();
|
|
|
|
return {
|
|
roomId: me?.voiceState?.roomId ?? this.voiceSessionInfo()?.roomId,
|
|
serverId: me?.voiceState?.serverId ?? this.currentRoom()?.id ?? this.voiceSessionInfo()?.serverId
|
|
};
|
|
}
|
|
|
|
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.voiceWorkspace.open(null);
|
|
} catch {
|
|
// Screen-share prompt was dismissed or failed.
|
|
}
|
|
}
|
|
|
|
private getUserPeerKey(user: User | null | undefined): string | null {
|
|
return user?.oderId || user?.id || null;
|
|
}
|
|
|
|
private buildStreamId(peerKey: string, kind: VoiceWorkspaceStreamItem['kind']): string {
|
|
return `${kind}:${peerKey}`;
|
|
}
|
|
|
|
private getRemoteScreenShareStream(user: User): { peerKey: string; stream: MediaStream } | null {
|
|
const peerKeys = [user.oderId, user.id].filter(
|
|
(candidate): candidate is string => !!candidate
|
|
);
|
|
|
|
for (const peerKey of peerKeys) {
|
|
const stream = this.screenShare.getRemoteScreenShareStream(peerKey);
|
|
|
|
if (stream && this.hasActiveVideo(stream)) {
|
|
return { peerKey, stream };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private getRemoteCameraStream(user: User): { peerKey: string; stream: MediaStream } | null {
|
|
const peerKeys = [user.oderId, user.id].filter(
|
|
(candidate): candidate is string => !!candidate
|
|
);
|
|
|
|
for (const peerKey of peerKeys) {
|
|
const stream = this.webrtc.getRemoteCameraStream(peerKey);
|
|
|
|
if (stream && this.hasActiveVideo(stream)) {
|
|
return { peerKey, stream };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private hasActiveVideo(stream: MediaStream): boolean {
|
|
return stream.getVideoTracks().some((track) => track.readyState === 'live');
|
|
}
|
|
|
|
private hasActiveAudio(stream: MediaStream): boolean {
|
|
return stream.getAudioTracks().some((track) => track.readyState === 'live');
|
|
}
|
|
|
|
private ensureMiniWindowPosition(): void {
|
|
const bounds = this.getWorkspaceBounds();
|
|
|
|
if (bounds.width === 0 || bounds.height === 0) {
|
|
return;
|
|
}
|
|
|
|
if (!this.voiceWorkspace.hasCustomMiniWindowPosition()) {
|
|
this.voiceWorkspace.setMiniWindowPosition(
|
|
this.clampMiniWindowPosition({
|
|
left: bounds.width - this.miniWindowWidth - 20,
|
|
top: bounds.height - this.miniWindowHeight - 20
|
|
}),
|
|
false
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
this.voiceWorkspace.setMiniWindowPosition(
|
|
this.clampMiniWindowPosition(this.voiceWorkspace.miniWindowPosition()),
|
|
true
|
|
);
|
|
}
|
|
|
|
private clampMiniWindowPosition(position: VoiceWorkspacePosition): VoiceWorkspacePosition {
|
|
const bounds = this.getWorkspaceBounds();
|
|
const minLeft = 8;
|
|
const minTop = 8;
|
|
const maxLeft = Math.max(minLeft, bounds.width - this.miniWindowWidth - 8);
|
|
const maxTop = Math.max(minTop, bounds.height - this.miniWindowHeight - 8);
|
|
|
|
return {
|
|
left: this.clamp(position.left, minLeft, maxLeft),
|
|
top: this.clamp(position.top, minTop, maxTop)
|
|
};
|
|
}
|
|
|
|
private getWorkspaceBounds(): DOMRect {
|
|
return this.elementRef.nativeElement.getBoundingClientRect();
|
|
}
|
|
|
|
private observeRemoteStream(peerKey: string): void {
|
|
const stream = this.screenShare.getRemoteScreenShareStream(peerKey);
|
|
const existing = this.observedRemoteStreams.get(peerKey);
|
|
|
|
if (!stream) {
|
|
if (existing) {
|
|
existing.cleanup();
|
|
this.observedRemoteStreams.delete(peerKey);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (existing?.stream === stream) {
|
|
return;
|
|
}
|
|
|
|
existing?.cleanup();
|
|
|
|
const onChanged = () => this.bumpRemoteStreamRevision();
|
|
const trackCleanups: (() => void)[] = [];
|
|
const bindTrack = (track: MediaStreamTrack) => {
|
|
if (track.kind !== 'video') {
|
|
return;
|
|
}
|
|
|
|
const onTrackChanged = () => onChanged();
|
|
|
|
track.addEventListener('ended', onTrackChanged);
|
|
track.addEventListener('mute', onTrackChanged);
|
|
track.addEventListener('unmute', onTrackChanged);
|
|
|
|
trackCleanups.push(() => {
|
|
track.removeEventListener('ended', onTrackChanged);
|
|
track.removeEventListener('mute', onTrackChanged);
|
|
track.removeEventListener('unmute', onTrackChanged);
|
|
});
|
|
};
|
|
|
|
stream.getVideoTracks().forEach((track) => bindTrack(track));
|
|
|
|
const onAddTrack = (event: MediaStreamTrackEvent) => {
|
|
bindTrack(event.track);
|
|
onChanged();
|
|
};
|
|
const onRemoveTrack = () => onChanged();
|
|
|
|
stream.addEventListener('addtrack', onAddTrack);
|
|
stream.addEventListener('removetrack', onRemoveTrack);
|
|
|
|
this.observedRemoteStreams.set(peerKey, {
|
|
stream,
|
|
cleanup: () => {
|
|
stream.removeEventListener('addtrack', onAddTrack);
|
|
stream.removeEventListener('removetrack', onRemoveTrack);
|
|
trackCleanups.forEach((cleanup) => cleanup());
|
|
}
|
|
});
|
|
|
|
onChanged();
|
|
}
|
|
|
|
private pruneObservedRemoteStreams(activePeerKeys: Set<string>): void {
|
|
for (const [peerKey, observed] of this.observedRemoteStreams.entries()) {
|
|
if (activePeerKeys.has(peerKey)) {
|
|
continue;
|
|
}
|
|
|
|
observed.cleanup();
|
|
this.observedRemoteStreams.delete(peerKey);
|
|
}
|
|
}
|
|
|
|
private cleanupObservedRemoteStreams(): void {
|
|
for (const observed of this.observedRemoteStreams.values()) {
|
|
observed.cleanup();
|
|
}
|
|
|
|
this.observedRemoteStreams.clear();
|
|
}
|
|
|
|
private scheduleHeaderHide(): void {
|
|
this.clearHeaderHideTimeout();
|
|
|
|
this.headerHideTimeoutId = setTimeout(() => {
|
|
this.showWorkspaceHeader.set(false);
|
|
this.headerHideTimeoutId = null;
|
|
}, 2200);
|
|
}
|
|
|
|
private revealWorkspaceChrome(): void {
|
|
this.showWorkspaceHeader.set(true);
|
|
this.scheduleHeaderHide();
|
|
}
|
|
|
|
private clearHeaderHideTimeout(): void {
|
|
if (this.headerHideTimeoutId === null) {
|
|
return;
|
|
}
|
|
|
|
clearTimeout(this.headerHideTimeoutId);
|
|
this.headerHideTimeoutId = null;
|
|
}
|
|
|
|
private clamp(value: number, min: number, max: number): number {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
}
|