Files
Toju/toju-app/src/app/features/room/voice-workspace/voice-workspace.component.ts
Myx dea114aed0
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
feat: Response mobile layout support v1
2026-05-18 03:03:55 +02:00

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);
}
}