Files
Toju/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts
2026-03-30 04:41:58 +02:00

954 lines
26 KiB
TypeScript

/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
inject,
computed,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMessageSquare,
lucideMic,
lucideMicOff,
lucideChevronLeft,
lucideMonitor,
lucideVideo,
lucideHash,
lucideUsers,
lucidePlus,
lucideVolumeX
} from '@ng-icons/lucide';
import {
selectOnlineUsers,
selectCurrentUser,
selectIsCurrentUserAdmin
} from '../../../store/users/users.selectors';
import {
selectCurrentRoom,
selectActiveChannelId,
selectTextChannels,
selectVoiceChannels
} from '../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../store/users/users.actions';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { MessagesActions } from '../../../store/messages/messages.actions';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ScreenShareFacade } from '../../../domains/screen-share';
import { NotificationsFacade } from '../../../domains/notifications';
import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection';
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
import { VoicePlaybackService } from '../../../domains/voice-connection/application/voice-playback.service';
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
import { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules';
import {
ContextMenuComponent,
UserAvatarComponent,
ConfirmDialogComponent,
UserVolumeMenuComponent
} from '../../../shared';
import {
Channel,
ChatEvent,
RoomMember,
Room,
User
} from '../../../shared-kernel';
import { v4 as uuidv4 } from 'uuid';
type TabView = 'channels' | 'users';
@Component({
selector: 'app-rooms-side-panel',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
VoiceControlsComponent,
ContextMenuComponent,
UserVolumeMenuComponent,
UserAvatarComponent,
ConfirmDialogComponent
],
viewProviders: [
provideIcons({
lucideMessageSquare,
lucideMic,
lucideMicOff,
lucideChevronLeft,
lucideMonitor,
lucideVideo,
lucideHash,
lucideUsers,
lucidePlus,
lucideVolumeX
})
],
templateUrl: './rooms-side-panel.component.html'
})
export class RoomsSidePanelComponent {
private store = inject(Store);
private realtime = inject(RealtimeSessionFacade);
private voiceConnection = inject(VoiceConnectionFacade);
private screenShare = inject(ScreenShareFacade);
private notifications = inject(NotificationsFacade);
private voiceSessionService = inject(VoiceSessionFacade);
private voiceWorkspace = inject(VoiceWorkspaceService);
private voicePlayback = inject(VoicePlaybackService);
voiceActivity = inject(VoiceActivityService);
activeTab = signal<TabView>('channels');
showFloatingControls = this.voiceSessionService.showFloatingControls;
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
onlineUsers = this.store.selectSignal(selectOnlineUsers);
currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
activeChannelId = this.store.selectSignal(selectActiveChannelId);
textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels);
roomMembers = computed(() => this.currentRoom()?.members ?? []);
roomMemberIdentifiers = computed(() => {
const identifiers = new Set<string>();
for (const member of this.roomMembers()) {
this.addIdentifiers(identifiers, member);
}
return identifiers;
});
onlineRoomUsers = computed(() => {
const memberIdentifiers = this.roomMemberIdentifiers();
return this.onlineUsers().filter((user) => !this.isCurrentUserIdentity(user) && this.matchesIdentifiers(memberIdentifiers, user));
});
offlineRoomMembers = computed(() => {
const onlineIdentifiers = new Set<string>();
for (const user of this.onlineRoomUsers()) {
this.addIdentifiers(onlineIdentifiers, user);
}
this.addIdentifiers(onlineIdentifiers, this.currentUser());
return this.roomMembers().filter((member) => !this.matchesIdentifiers(onlineIdentifiers, member));
});
knownUserCount = computed(() => {
const memberIds = new Set(
this.roomMembers()
.map((member) => this.roomMemberKey(member))
.filter(Boolean)
);
const current = this.currentUser();
if (current) {
memberIds.add(current.oderId || current.id);
}
return memberIds.size;
});
showChannelMenu = signal(false);
channelMenuX = signal(0);
channelMenuY = signal(0);
contextChannel = signal<Channel | null>(null);
renamingChannelId = signal<string | null>(null);
channelNameError = signal<string | null>(null);
showCreateChannelDialog = signal(false);
createChannelType = signal<'text' | 'voice'>('text');
newChannelName = '';
showUserMenu = signal(false);
userMenuX = signal(0);
userMenuY = signal(0);
contextMenuUser = signal<User | null>(null);
showVolumeMenu = signal(false);
volumeMenuX = signal(0);
volumeMenuY = signal(0);
volumeMenuPeerId = signal('');
volumeMenuDisplayName = signal('');
draggedVoiceUserId = signal<string | null>(null);
dragTargetVoiceChannelId = signal<string | null>(null);
private roomMemberKey(member: RoomMember): string {
return member.oderId || member.id;
}
private addIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string } | null | undefined): void {
if (!entity)
return;
if (entity.id) {
identifiers.add(entity.id);
}
if (entity.oderId) {
identifiers.add(entity.oderId);
}
}
private matchesIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string }): boolean {
return !!((entity.id && identifiers.has(entity.id)) || (entity.oderId && identifiers.has(entity.oderId)));
}
private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean {
const current = this.currentUser();
return !!current && (
(typeof entity.id === 'string' && entity.id === current.id)
|| (typeof entity.oderId === 'string' && entity.oderId === current.oderId)
);
}
canManageChannels(): boolean {
const room = this.currentRoom();
const user = this.currentUser();
if (!room || !user)
return false;
if (room.hostId === user.id)
return true;
const perms = room.permissions || {};
if (user.role === 'admin' && perms.adminsManageRooms)
return true;
if (user.role === 'moderator' && perms.moderatorsManageRooms)
return true;
return false;
}
selectTextChannel(channelId: string) {
if (this.renamingChannelId())
return;
this.voiceWorkspace.showChat();
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
}
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
evt.preventDefault();
this.contextChannel.set(channel);
this.channelMenuX.set(evt.clientX);
this.channelMenuY.set(evt.clientY);
this.showChannelMenu.set(true);
}
closeChannelMenu() {
this.showChannelMenu.set(false);
}
startRename() {
const ch = this.contextChannel();
this.closeChannelMenu();
this.channelNameError.set(null);
if (ch) {
this.renamingChannelId.set(ch.id);
}
}
confirmRename(event: Event) {
const input = event.target as HTMLInputElement;
const name = normalizeChannelName(input.value);
const channelId = this.renamingChannelId();
if (!channelId) {
return;
}
const validationError = this.getChannelNameError(name, channelId);
if (validationError) {
this.channelNameError.set(validationError);
requestAnimationFrame(() => {
input.focus();
input.select();
});
return;
}
this.channelNameError.set(null);
const currentName = this.currentRoom()?.channels?.find((channel) => channel.id === channelId)?.name;
if (currentName !== name) {
this.store.dispatch(RoomsActions.renameChannel({ channelId, name }));
}
this.renamingChannelId.set(null);
}
cancelRename() {
this.channelNameError.set(null);
this.renamingChannelId.set(null);
}
deleteChannel() {
const ch = this.contextChannel();
this.closeChannelMenu();
if (ch) {
this.store.dispatch(RoomsActions.removeChannel({ channelId: ch.id }));
}
}
toggleChannelNotifications(): void {
const channel = this.contextChannel();
const roomId = this.currentRoom()?.id;
this.closeChannelMenu();
if (!channel || channel.type !== 'text' || !roomId) {
return;
}
this.notifications.setChannelMuted(
roomId,
channel.id,
!this.notifications.isChannelMuted(roomId, channel.id)
);
}
isContextChannelMuted(): boolean {
const channel = this.contextChannel();
const roomId = this.currentRoom()?.id;
return !!channel && channel.type === 'text' && !!roomId && this.notifications.isChannelMuted(roomId, channel.id);
}
channelUnreadCount(channelId: string): number {
const roomId = this.currentRoom()?.id;
return roomId ? this.notifications.channelUnreadCount(roomId, channelId) : 0;
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}
resyncMessages() {
this.closeChannelMenu();
const room = this.currentRoom();
if (!room) {
return;
}
this.store.dispatch(MessagesActions.startSync());
const peers = this.realtime.getConnectedPeers();
const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id };
peers.forEach((pid) => {
try {
this.realtime.sendToPeer(pid, inventoryRequest);
} catch {
return;
}
});
}
createChannel(type: 'text' | 'voice') {
this.createChannelType.set(type);
this.newChannelName = '';
this.channelNameError.set(null);
this.showCreateChannelDialog.set(true);
}
confirmCreateChannel() {
const name = normalizeChannelName(this.newChannelName);
const validationError = this.getChannelNameError(name);
if (validationError) {
this.channelNameError.set(validationError);
return;
}
const type = this.createChannelType();
const existing = type === 'text' ? this.textChannels() : this.voiceChannels();
const channel: Channel = {
id: type === 'voice' ? `vc-${uuidv4().slice(0, 8)}` : uuidv4().slice(0, 8),
name,
type,
position: existing.length
};
this.store.dispatch(RoomsActions.addChannel({ channel }));
this.channelNameError.set(null);
this.showCreateChannelDialog.set(false);
}
cancelCreateChannel() {
this.channelNameError.set(null);
this.showCreateChannelDialog.set(false);
}
clearChannelNameError(): void {
if (this.channelNameError()) {
this.channelNameError.set(null);
}
}
private getChannelNameError(name: string, excludeChannelId?: string): string | null {
if (!name) {
return 'Channel name is required.';
}
const channels = this.currentRoom()?.channels ?? [];
const channelType = excludeChannelId
? channels.find((channel) => channel.id === excludeChannelId)?.type
: this.createChannelType();
if (!channelType) {
return null;
}
if (isChannelNameTaken(channels, name, channelType, excludeChannelId)) {
return 'Channel names must be unique within text or voice channels.';
}
return null;
}
openUserContextMenu(evt: MouseEvent, user: User) {
evt.preventDefault();
if (!this.isAdmin())
return;
this.contextMenuUser.set(user);
this.userMenuX.set(evt.clientX);
this.userMenuY.set(evt.clientY);
this.showUserMenu.set(true);
}
closeUserMenu() {
this.showUserMenu.set(false);
}
openVoiceUserVolumeMenu(evt: MouseEvent, user: User) {
evt.preventDefault();
const me = this.currentUser();
if (user.id === me?.id || user.oderId === me?.oderId)
return;
this.volumeMenuPeerId.set(user.oderId || user.id);
this.volumeMenuDisplayName.set(user.displayName);
this.volumeMenuX.set(evt.clientX);
this.volumeMenuY.set(evt.clientY);
this.showVolumeMenu.set(true);
}
changeUserRole(role: 'admin' | 'moderator' | 'member') {
const user = this.contextMenuUser();
const roomId = this.currentRoom()?.id;
this.closeUserMenu();
if (user) {
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
this.realtime.broadcastMessage({
type: 'role-change',
roomId,
targetUserId: user.id,
role
});
}
}
kickUserAction() {
const user = this.contextMenuUser();
this.closeUserMenu();
if (user) {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
}
}
joinVoice(roomId: string) {
const room = this.currentRoom();
const current = this.currentUser();
if (
room
&& current?.voiceState?.isConnected
&& current.voiceState.roomId === roomId
&& current.voiceState.serverId === room.id
) {
this.voiceWorkspace.open(null, { connectRemoteShares: true });
return;
}
if (room && room.permissions && room.permissions.allowVoice === false) {
return;
}
if (!room)
return;
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
if (!this.voiceConnection.isVoiceConnected()) {
if (current.id) {
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
}
})
);
}
} else {
return;
}
}
const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId;
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice();
enableVoicePromise
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
.catch(() => undefined);
}
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
this.updateVoiceStateStore(roomId, room, current);
this.trackCurrentUserMic();
this.startVoiceHeartbeat(roomId, room);
this.broadcastVoiceConnected(roomId, room, current);
this.startVoiceSession(roomId, room);
}
private trackCurrentUserMic(): void {
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
const micStream = this.voiceConnection.getRawMicStream();
if (userId && micStream) {
this.voiceActivity.trackLocalMic(userId, micStream);
}
}
private untrackCurrentUserMic(): void {
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
if (userId) {
this.voiceActivity.untrackLocalMic(userId);
}
}
private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void {
if (!current?.id)
return;
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: true,
isMuted: current.voiceState?.isMuted ?? false,
isDeafened: current.voiceState?.isDeafened ?? false,
roomId,
serverId: room.id
}
})
);
}
private startVoiceHeartbeat(roomId: string, room: Room): void {
this.voiceConnection.startVoiceHeartbeat(roomId, room.id);
}
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
this.voiceConnection.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
displayName: current?.displayName || 'User',
voiceState: {
isConnected: true,
isMuted: current?.voiceState?.isMuted ?? false,
isDeafened: current?.voiceState?.isDeafened ?? false,
roomId,
serverId: room.id
}
});
}
private startVoiceSession(roomId: string, room: Room): void {
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
this.voiceSessionService.startSession({
serverId: room.id,
serverName: room.name,
roomId,
roomName: voiceRoomName,
serverIcon: room.icon,
serverDescription: room.description,
serverRoute: `/room/${room.id}`
});
}
leaveVoice(roomId: string) {
const current = this.currentUser();
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId))
return;
this.voiceConnection.stopVoiceHeartbeat();
this.untrackCurrentUserMic();
this.voiceConnection.disableVoice();
if (current?.id) {
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
}
})
);
this.store.dispatch(
UsersActions.updateCameraState({
userId: current.id,
cameraState: { isEnabled: false }
})
);
}
this.voiceConnection.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
displayName: current?.displayName || 'User',
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
}
});
this.voiceSessionService.endSession();
}
voiceOccupancy(roomId: string): number {
return this.voiceUsersInRoom(roomId).length;
}
viewShare(userId: string) {
this.voiceWorkspace.focusStream(`screen:${userId}`, { connectRemoteShares: true });
}
viewStream(userId: string) {
const focusTarget = this.isUserSharing(userId)
? `screen:${userId}`
: `camera:${userId}`;
this.voiceWorkspace.focusStream(focusTarget, { connectRemoteShares: true });
}
canMoveVoiceUsers(): boolean {
return this.canManageChannels();
}
canDragVoiceUser(user: User): boolean {
return this.canMoveVoiceUsers() && !this.isCurrentUserIdentity(user) && !!user.voiceState?.isConnected;
}
onVoiceUserDragStart(event: DragEvent, user: User): void {
if (!this.canDragVoiceUser(user)) {
event.preventDefault();
return;
}
const dragId = user.id || user.oderId;
if (!dragId) {
event.preventDefault();
return;
}
this.draggedVoiceUserId.set(dragId);
event.dataTransfer?.setData('text/plain', dragId);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
}
onVoiceUserDragEnd(): void {
this.draggedVoiceUserId.set(null);
this.dragTargetVoiceChannelId.set(null);
}
onVoiceChannelDragOver(event: DragEvent, channelId: string): void {
if (!this.draggedVoiceUserId()) {
return;
}
event.preventDefault();
this.dragTargetVoiceChannelId.set(channelId);
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
}
onVoiceChannelDragLeave(channelId: string): void {
if (this.dragTargetVoiceChannelId() === channelId) {
this.dragTargetVoiceChannelId.set(null);
}
}
onVoiceChannelDrop(event: DragEvent, channelId: string): void {
event.preventDefault();
const draggedUserId = this.draggedVoiceUserId() || event.dataTransfer?.getData('text/plain') || null;
this.draggedVoiceUserId.set(null);
this.dragTargetVoiceChannelId.set(null);
if (!draggedUserId) {
return;
}
this.moveVoiceUserToChannel(draggedUserId, channelId);
}
private moveVoiceUserToChannel(draggedUserId: string, channelId: string): void {
const room = this.currentRoom();
const actor = this.currentUser();
if (!room || !actor || !this.canMoveVoiceUsers()) {
return;
}
const targetUser = this.onlineUsers().find((user) => user.id === draggedUserId || user.oderId === draggedUserId);
if (!targetUser?.voiceState?.isConnected || targetUser.voiceState.serverId !== room.id || targetUser.voiceState.roomId === channelId) {
return;
}
const movedVoiceState: Partial<User['voiceState']> = {
isConnected: true,
isMuted: targetUser.voiceState.isMuted,
isDeafened: targetUser.voiceState.isDeafened,
isSpeaking: targetUser.voiceState.isSpeaking,
isMutedByAdmin: targetUser.voiceState.isMutedByAdmin,
volume: targetUser.voiceState.volume,
roomId: channelId,
serverId: room.id
};
this.store.dispatch(UsersActions.updateVoiceState({
userId: targetUser.id,
voiceState: movedVoiceState
}));
this.realtime.broadcastMessage({
type: 'voice-channel-move',
roomId: room.id,
targetUserId: targetUser.oderId || targetUser.id,
voiceState: movedVoiceState,
displayName: targetUser.displayName
});
}
isUserLocallyMuted(user: User): boolean {
const peerId = user.oderId || user.id;
return this.voicePlayback.isUserMuted(peerId);
}
isUserOnCamera(userId: string): boolean {
const user = this.findKnownUser(userId);
if (!this.isUserInCurrentVoiceRoom(userId, user)) {
return false;
}
const current = this.currentUser();
if (current && (current.id === userId || current.oderId === userId)) {
return this.voiceConnection.isCameraEnabled();
}
if (user?.cameraState?.isEnabled === true) {
return true;
}
if (user?.cameraState?.isEnabled === false) {
return false;
}
return this.getPeerKeysForUser(user, userId)
.some((peerKey) => this.hasActiveVideoStream(this.voiceConnection.getRemoteCameraStream(peerKey)));
}
isUserSharing(userId: string): boolean {
const user = this.findKnownUser(userId);
if (!this.isUserInCurrentVoiceRoom(userId, user)) {
return false;
}
const current = this.currentUser();
if (current && (current.id === userId || current.oderId === userId)) {
return this.screenShare.isScreenSharing();
}
if (user?.screenShareState?.isSharing === true) {
return true;
}
if (user?.screenShareState?.isSharing === false) {
return false;
}
const stream = this.getPeerKeysForUser(user, userId)
.map((peerKey) => this.screenShare.getRemoteScreenShareStream(peerKey))
.find((candidate) => this.hasActiveVideoStream(candidate)) || null;
return this.hasActiveVideoStream(stream);
}
isUserStreaming(userId: string): boolean {
return this.isUserSharing(userId) || this.isUserOnCamera(userId);
}
getUserLiveIconName(userId: string): string {
return this.isUserSharing(userId) ? 'lucideMonitor' : 'lucideVideo';
}
voiceUsersInRoom(roomId: string) {
const room = this.currentRoom();
const me = this.currentUser();
const remoteUsers = this.onlineUsers().filter(
(user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id
);
if (
me?.voiceState?.isConnected &&
me.voiceState?.roomId === roomId &&
me.voiceState?.serverId === room?.id
) {
const meId = me.id;
const meOderId = me.oderId;
const alreadyIncluded = remoteUsers.some(
(user) => user.id === meId || user.oderId === meOderId
);
if (!alreadyIncluded) {
return [me, ...remoteUsers];
}
}
return remoteUsers;
}
isCurrentRoom(roomId: string): boolean {
const me = this.currentUser();
const room = this.currentRoom();
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
}
voiceEnabled(): boolean {
const room = this.currentRoom();
return room?.permissions?.allowVoice !== false;
}
getPeerLatency(user: User): number | null {
const latencies = this.voiceConnection.peerLatencies();
return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null;
}
getPingColorClass(user: User): string {
const ms = this.getPeerLatency(user);
if (ms === null)
return 'bg-gray-500';
if (ms < 100)
return 'bg-green-500';
if (ms < 200)
return 'bg-yellow-500';
if (ms < 350)
return 'bg-orange-500';
return 'bg-red-500';
}
private findKnownUser(userId: string): User | null {
const current = this.currentUser();
if (current && (current.id === userId || current.oderId === userId)) {
return current;
}
return this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) ?? null;
}
private isUserInCurrentVoiceRoom(userId: string, user: User | null): boolean {
const currentVoiceState = this.currentUser()?.voiceState;
const current = this.currentUser();
if (!currentVoiceState?.isConnected || !currentVoiceState.roomId || !currentVoiceState.serverId) {
return false;
}
if (current && (current.id === userId || current.oderId === userId)) {
return true;
}
return !!user?.voiceState?.isConnected
&& user.voiceState.roomId === currentVoiceState.roomId
&& user.voiceState.serverId === currentVoiceState.serverId;
}
private getPeerKeysForUser(user: User | null, userId: string): string[] {
return [
user?.oderId,
user?.id,
userId
].filter(
(candidate): candidate is string => !!candidate
);
}
private hasActiveVideoStream(stream: MediaStream | null): boolean {
return !!stream && stream.getVideoTracks().some((track) => track.readyState === 'live');
}
}