/* 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); 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 | null = null; private readonly observedRemoteStreams = new Map void; }>(); readonly miniPreviewRef = viewChild>('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('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(() => { 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(); 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 { 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 { 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 { 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): 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); } }