FRE-603: Add Presence & Visibility Layer UI components
- CollaboratorList: Display connected users with presence state - RemoteCursorOverlay: Render remote cursors in editor - EditingIndicator: Show active editors and their context - Component index for clean imports - Tests for CollaboratorList Architecture: - Polling-based presence updates (100ms for cursors, 500ms for editors) - Color-coded user indicators - Line:column cursor positioning - Selection highlighting with transparency Files: - src/components/collaboration/collaborator-list.tsx - src/components/collaboration/remote-cursor-overlay.tsx - src/components/collaboration/editing-indicator.tsx - src/components/collaboration/index.ts - src/components/collaboration/collaborator-list.test.tsx Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
577
src/lib/collaboration/presence-manager.ts
Normal file
577
src/lib/collaboration/presence-manager.ts
Normal file
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* Presence Manager
|
||||
* Tracks connected users, their cursor positions, and idle state
|
||||
* Integrates with WebSocket for real-time presence updates
|
||||
*/
|
||||
|
||||
import { WebSocketProvider } from 'y-websocket';
|
||||
import { WebSocketConnection } from './websocket-connection';
|
||||
|
||||
/**
|
||||
* User presence state
|
||||
*/
|
||||
export interface UserPresence {
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
cursorPosition: number | null;
|
||||
selectionStart: number | null;
|
||||
selectionEnd: number | null;
|
||||
editingContext: string | null; // e.g., scene ID or element being edited
|
||||
lastActivity: Date;
|
||||
status: 'active' | 'idle' | 'away';
|
||||
}
|
||||
|
||||
/**
|
||||
* Presence update message for WebSocket
|
||||
*/
|
||||
export interface PresenceUpdateMessage {
|
||||
type: 'presence:update';
|
||||
userId: string;
|
||||
presence: Omit<UserPresence, 'lastActivity'>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* User join event
|
||||
*/
|
||||
export interface UserJoinMessage {
|
||||
type: 'presence:join';
|
||||
userId: string;
|
||||
presence: Omit<UserPresence, 'lastActivity' | 'status'>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* User leave event
|
||||
*/
|
||||
export interface UserLeaveMessage {
|
||||
type: 'presence:leave';
|
||||
userId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full presence state from server
|
||||
*/
|
||||
export interface PresenceStateMessage {
|
||||
type: 'presence:state';
|
||||
users: Record<string, Omit<UserPresence, 'lastActivity'>>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presence message type discriminator
|
||||
*/
|
||||
export type PresenceMessage =
|
||||
| PresenceUpdateMessage
|
||||
| UserJoinMessage
|
||||
| UserLeaveMessage
|
||||
| PresenceStateMessage;
|
||||
|
||||
/**
|
||||
* Options for PresenceManager
|
||||
*/
|
||||
export interface PresenceManagerOptions {
|
||||
userId: string;
|
||||
userName: string;
|
||||
userColor: string;
|
||||
idleTimeoutMs?: number;
|
||||
broadcastIntervalMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback types for presence events
|
||||
*/
|
||||
export type OnUserJoin = (userId: string, presence: Omit<UserPresence, 'lastActivity'>) => void;
|
||||
export type OnUserLeave = (userId: string) => void;
|
||||
export type OnPresenceUpdate = (userId: string, presence: UserPresence) => void;
|
||||
export type OnPresenceState = (users: Record<string, UserPresence>) => void;
|
||||
|
||||
/**
|
||||
* PresenceManager class
|
||||
* Manages local user presence and tracks remote users
|
||||
*/
|
||||
export class PresenceManager {
|
||||
private userId: string;
|
||||
private userName: string;
|
||||
private userColor: string;
|
||||
private idleTimeoutMs: number;
|
||||
private broadcastIntervalMs: number;
|
||||
|
||||
private provider: WebSocketProvider | null = null;
|
||||
private connection: WebSocketConnection | null = null;
|
||||
|
||||
// Remote users' presence state
|
||||
private remoteUsers: Map<string, UserPresence> = new Map();
|
||||
|
||||
// Local user's current state
|
||||
private localPresence: UserPresence;
|
||||
|
||||
// Timers
|
||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private broadcastTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Event callbacks
|
||||
private onUserJoinCallbacks: Set<OnUserJoin> = new Set();
|
||||
private onUserLeaveCallbacks: Set<OnUserLeave> = new Set();
|
||||
private onPresenceUpdateCallbacks: Set<OnPresenceUpdate> = new Set();
|
||||
private onPresenceStateCallbacks: Set<OnPresenceState> = new Set();
|
||||
|
||||
// Activity tracking
|
||||
private lastActivityTime: Date = new Date();
|
||||
private isInitialized: boolean = false;
|
||||
|
||||
constructor(options: PresenceManagerOptions) {
|
||||
this.userId = options.userId;
|
||||
this.userName = options.userName;
|
||||
this.userColor = options.userColor;
|
||||
this.idleTimeoutMs = options.idleTimeoutMs || 30000; // 30 seconds default
|
||||
this.broadcastIntervalMs = options.broadcastIntervalMs || 1000; // 1 second default
|
||||
|
||||
// Initialize local presence
|
||||
this.localPresence = {
|
||||
userId: this.userId,
|
||||
name: this.userName,
|
||||
color: this.userColor,
|
||||
cursorPosition: null,
|
||||
selectionStart: null,
|
||||
selectionEnd: null,
|
||||
editingContext: null,
|
||||
lastActivity: new Date(),
|
||||
status: 'active',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the presence manager with a WebSocket connection
|
||||
*/
|
||||
initialize(connection: WebSocketConnection): void {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.connection = connection;
|
||||
this.provider = connection.getProvider();
|
||||
|
||||
// Listen for Yjs awareness updates (y-websocket uses awareness for presence)
|
||||
this.provider.on('awareness', (event: { states: Map<number, any> }) => {
|
||||
this.processAwarenessUpdate(event.states);
|
||||
});
|
||||
|
||||
// Listen for generic message events for custom presence messages
|
||||
this.provider.on('message', (event: { message: PresenceMessage }) => {
|
||||
this.processPresenceMessage(event.message);
|
||||
});
|
||||
|
||||
// Start idle monitoring
|
||||
this.startIdleMonitor();
|
||||
|
||||
// Start periodic presence broadcast
|
||||
this.startPresenceBroadcast();
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log(`[PresenceManager] Initialized for user ${this.userName} (${this.userId})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the presence manager
|
||||
*/
|
||||
shutdown(): void {
|
||||
if (this.provider) {
|
||||
this.provider.off('awareness', this.handleAwarenessUpdate);
|
||||
this.provider.off('message', this.handlePresenceMessage);
|
||||
}
|
||||
|
||||
if (this.broadcastTimer) {
|
||||
clearInterval(this.broadcastTimer);
|
||||
this.broadcastTimer = null;
|
||||
}
|
||||
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
|
||||
// Send leave message
|
||||
this.sendLeaveMessage();
|
||||
|
||||
this.isInitialized = false;
|
||||
console.log(`[PresenceManager] Shutdown for user ${this.userName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cursor position
|
||||
*/
|
||||
updateCursorPosition(position: number, selectionStart?: number, selectionEnd?: number): void {
|
||||
this.localPresence.cursorPosition = position;
|
||||
this.localPresence.selectionStart = selectionStart ?? null;
|
||||
this.localPresence.selectionEnd = selectionEnd ?? null;
|
||||
this.recordActivity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update editing context (e.g., which scene or element is being edited)
|
||||
*/
|
||||
updateEditingContext(context: string | null): void {
|
||||
this.localPresence.editingContext = context;
|
||||
this.recordActivity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connected users
|
||||
*/
|
||||
getConnectedUsers(): UserPresence[] {
|
||||
return Array.from(this.remoteUsers.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific user by ID
|
||||
*/
|
||||
getUser(userId: string): UserPresence | undefined {
|
||||
return this.remoteUsers.get(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the local user's presence
|
||||
*/
|
||||
getLocalPresence(): UserPresence {
|
||||
return this.localPresence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is currently idle
|
||||
*/
|
||||
isUserIdle(userId: string): boolean {
|
||||
const user = userId === this.userId ? this.localPresence : this.remoteUsers.get(userId);
|
||||
return user?.status === 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Record user activity and reset idle timer
|
||||
*/
|
||||
recordActivity(): void {
|
||||
this.lastActivityTime = new Date();
|
||||
const wasIdle = this.localPresence.status === 'idle';
|
||||
|
||||
// Update local status
|
||||
this.localPresence.lastActivity = this.lastActivityTime;
|
||||
this.localPresence.status = 'active';
|
||||
|
||||
// If was idle, notify listeners
|
||||
if (wasIdle) {
|
||||
this.onPresenceUpdateCallbacks.forEach(callback => {
|
||||
callback(this.userId, { ...this.localPresence });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for user join events
|
||||
*/
|
||||
onUserJoin(callback: OnUserJoin): void {
|
||||
this.onUserJoinCallbacks.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user join callback
|
||||
*/
|
||||
offUserJoin(callback: OnUserJoin): void {
|
||||
this.onUserJoinCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for user leave events
|
||||
*/
|
||||
onUserLeave(callback: OnUserLeave): void {
|
||||
this.onUserLeaveCallbacks.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user leave callback
|
||||
*/
|
||||
offUserLeave(callback: OnUserLeave): void {
|
||||
this.onUserLeaveCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for presence updates
|
||||
*/
|
||||
onPresenceUpdate(callback: OnPresenceUpdate): void {
|
||||
this.onPresenceUpdateCallbacks.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove presence update callback
|
||||
*/
|
||||
offPresenceUpdate(callback: OnPresenceUpdate): void {
|
||||
this.onPresenceUpdateCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for full presence state
|
||||
*/
|
||||
onPresenceState(callback: OnPresenceState): void {
|
||||
this.onPresenceStateCallbacks.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove presence state callback
|
||||
*/
|
||||
offPresenceState(callback: OnPresenceState): void {
|
||||
this.onPresenceStateCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send presence update to server
|
||||
*/
|
||||
private sendPresenceUpdate(): void {
|
||||
if (!this.provider) return;
|
||||
|
||||
// Update awareness state (y-websocket standard)
|
||||
const awareness = this.provider.awareness;
|
||||
if (awareness) {
|
||||
const currentState = awareness.getLocalState();
|
||||
awareness.setLocalStateField('presence', {
|
||||
userId: this.localPresence.userId,
|
||||
name: this.localPresence.name,
|
||||
color: this.localPresence.color,
|
||||
cursorPosition: this.localPresence.cursorPosition,
|
||||
selectionStart: this.localPresence.selectionStart,
|
||||
selectionEnd: this.localPresence.selectionEnd,
|
||||
editingContext: this.localPresence.editingContext,
|
||||
status: this.localPresence.status,
|
||||
});
|
||||
}
|
||||
|
||||
// Also send custom message for backward compatibility
|
||||
const message: PresenceUpdateMessage = {
|
||||
type: 'presence:update',
|
||||
userId: this.userId,
|
||||
presence: {
|
||||
userId: this.localPresence.userId,
|
||||
name: this.localPresence.name,
|
||||
color: this.localPresence.color,
|
||||
cursorPosition: this.localPresence.cursorPosition,
|
||||
selectionStart: this.localPresence.selectionStart,
|
||||
selectionEnd: this.localPresence.selectionEnd,
|
||||
editingContext: this.localPresence.editingContext,
|
||||
status: this.localPresence.status,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.provider.send(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send user leave message
|
||||
*/
|
||||
private sendLeaveMessage(): void {
|
||||
if (!this.provider) return;
|
||||
|
||||
const message: UserLeaveMessage = {
|
||||
type: 'presence:leave',
|
||||
userId: this.userId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.provider.send(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start idle monitoring timer
|
||||
*/
|
||||
private startIdleMonitor(): void {
|
||||
const checkIdle = () => {
|
||||
const now = new Date();
|
||||
const idleDuration = now.getTime() - this.lastActivityTime.getTime();
|
||||
|
||||
if (idleDuration >= this.idleTimeoutMs && this.localPresence.status !== 'idle') {
|
||||
this.localPresence.status = 'idle';
|
||||
this.onPresenceUpdateCallbacks.forEach(callback => {
|
||||
callback(this.userId, { ...this.localPresence });
|
||||
});
|
||||
console.log(`[PresenceManager] User ${this.userName} marked as idle`);
|
||||
}
|
||||
|
||||
this.idleTimer = setTimeout(checkIdle, 1000);
|
||||
};
|
||||
|
||||
this.idleTimer = setTimeout(checkIdle, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic presence broadcast
|
||||
*/
|
||||
private startPresenceBroadcast(): void {
|
||||
this.broadcastTimer = setInterval(() => {
|
||||
this.sendPresenceUpdate();
|
||||
}, this.broadcastIntervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process awareness update from y-websocket
|
||||
*/
|
||||
private processAwarenessUpdate(states: Map<number, any>): void {
|
||||
states.forEach((state, clientId) => {
|
||||
if (clientId.toString() === this.userId) {
|
||||
return; // Skip own state
|
||||
}
|
||||
|
||||
if (state.presence) {
|
||||
const presence: UserPresence = {
|
||||
...state.presence,
|
||||
lastActivity: new Date(state.lastActivity || Date.now()),
|
||||
};
|
||||
|
||||
const wasKnown = this.remoteUsers.has(presence.userId);
|
||||
this.remoteUsers.set(presence.userId, presence);
|
||||
|
||||
if (!wasKnown) {
|
||||
this.onUserJoinCallbacks.forEach(callback => {
|
||||
callback(presence.userId, {
|
||||
userId: presence.userId,
|
||||
name: presence.name,
|
||||
color: presence.color,
|
||||
cursorPosition: presence.cursorPosition,
|
||||
selectionStart: presence.selectionStart,
|
||||
selectionEnd: presence.selectionEnd,
|
||||
editingContext: presence.editingContext,
|
||||
status: presence.status,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
this.onPresenceUpdateCallbacks.forEach(callback => {
|
||||
callback(presence.userId, presence);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up disconnected users
|
||||
states.forEach((_, clientId) => {
|
||||
if (!this.remoteUsers.has(clientId.toString())) {
|
||||
const removedUserId = clientId.toString();
|
||||
// Keep users that have sent presence
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process custom presence message
|
||||
*/
|
||||
private processPresenceMessage(message: PresenceMessage): void {
|
||||
switch (message.type) {
|
||||
case 'presence:update':
|
||||
const existingUser = this.remoteUsers.get(message.userId);
|
||||
const updatedPresence: UserPresence = {
|
||||
...message.presence,
|
||||
lastActivity: new Date(message.timestamp),
|
||||
};
|
||||
|
||||
this.remoteUsers.set(message.userId, updatedPresence);
|
||||
|
||||
if (!existingUser) {
|
||||
// New user
|
||||
this.onUserJoinCallbacks.forEach(callback => {
|
||||
callback(message.userId, message.presence);
|
||||
});
|
||||
}
|
||||
|
||||
this.onPresenceUpdateCallbacks.forEach(callback => {
|
||||
callback(message.userId, updatedPresence);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'presence:join':
|
||||
const joinPresence: UserPresence = {
|
||||
...message.presence,
|
||||
lastActivity: new Date(message.timestamp),
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
this.remoteUsers.set(message.userId, joinPresence);
|
||||
this.onUserJoinCallbacks.forEach(callback => {
|
||||
callback(message.userId, message.presence);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'presence:leave':
|
||||
this.remoteUsers.delete(message.userId);
|
||||
this.onUserLeaveCallbacks.forEach(callback => {
|
||||
callback(message.userId);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'presence:state':
|
||||
// Full state sync from server
|
||||
this.remoteUsers.clear();
|
||||
Object.entries(message.users).forEach(([userId, presence]) => {
|
||||
const userPresence: UserPresence = {
|
||||
...presence,
|
||||
lastActivity: new Date(presence.lastActivity as unknown as number || Date.now()),
|
||||
};
|
||||
this.remoteUsers.set(userId, userPresence);
|
||||
});
|
||||
|
||||
this.onPresenceStateCallbacks.forEach(callback => {
|
||||
callback(Object.fromEntries(this.remoteUsers.entries()));
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Store handler references for cleanup
|
||||
private handleAwarenessUpdate = (event: { states: Map<number, any> }) => {
|
||||
this.processAwarenessUpdate(event.states);
|
||||
};
|
||||
|
||||
private handlePresenceMessage = (event: { message: PresenceMessage }) => {
|
||||
this.processPresenceMessage(event.message);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random color for a user (for cursor/display)
|
||||
*/
|
||||
export function generateUserColor(userId: string): string {
|
||||
// Use a deterministic hash to generate consistent colors
|
||||
const colors = [
|
||||
'#ef4444', // red
|
||||
'#f97316', // orange
|
||||
'#eab308', // yellow
|
||||
'#22c55e', // green
|
||||
'#06b6d4', // cyan
|
||||
'#3b82f6', // blue
|
||||
'#8b5cf6', // violet
|
||||
'#ec4899', // pink
|
||||
];
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = userId.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default user colors for the application
|
||||
*/
|
||||
export const DEFAULT_USER_COLORS = [
|
||||
'#ef4444', // red
|
||||
'#f97316', // orange
|
||||
'#eab308', // yellow
|
||||
'#22c55e', // green
|
||||
'#06b6d4', // cyan
|
||||
'#3b82f6', // blue
|
||||
'#8b5cf6', // violet
|
||||
'#ec4899', // pink
|
||||
'#6366f1', // indigo
|
||||
'#14b8a6', // teal
|
||||
];
|
||||
|
||||
</content>
|
||||
<parameter=filePath>
|
||||
/home/mike/code/FrenoCorp/src/lib/collaboration/presence-manager.ts
|
||||
355
src/lib/video/webrtc-video-manager.ts
Normal file
355
src/lib/video/webrtc-video-manager.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* WebRTC Video Manager
|
||||
* Handles P2P video connections using PeerJS
|
||||
* Manages peer connections, streams, and signaling
|
||||
*/
|
||||
|
||||
import { Peer } from 'peerjs';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export type PeerConnectionState = 'disconnected' | 'connecting' | 'connected' | 'disconnecting' | 'error';
|
||||
|
||||
export type ConnectionQuality = 'excellent' | 'good' | 'fair' | 'poor';
|
||||
|
||||
export interface WebRTCVideoManagerOptions {
|
||||
peerId?: string;
|
||||
serverUrl?: string;
|
||||
audioEnabled?: boolean;
|
||||
videoEnabled?: boolean;
|
||||
turnServers?: RTCIceServer[];
|
||||
}
|
||||
|
||||
export interface PeerConnection {
|
||||
peerId: string;
|
||||
connection: DataConnection;
|
||||
stream: MediaStream | null;
|
||||
state: PeerConnectionState;
|
||||
quality: ConnectionQuality;
|
||||
connectedAt: Date;
|
||||
}
|
||||
|
||||
export interface VideoManagerEvents {
|
||||
'peer:connected': (peerId: string, stream: MediaStream) => void;
|
||||
'peer:disconnected': (peerId: string) => void;
|
||||
'peer:error': (peerId: string, error: Error) => void;
|
||||
'local:stream': (stream: MediaStream) => void;
|
||||
'connection:quality': (peerId: string, quality: ConnectionQuality) => void;
|
||||
'state:changed': (state: PeerConnectionState) => void;
|
||||
}
|
||||
|
||||
class DataConnection {
|
||||
private peer: Peer;
|
||||
private conn: any;
|
||||
private stream: MediaStream | null = null;
|
||||
private listeners: Map<string, Set<(...args: any[]) => void>> = new Map();
|
||||
|
||||
constructor(peer: Peer, conn: any) {
|
||||
this.peer = peer;
|
||||
this.conn = conn;
|
||||
|
||||
this.conn.on('stream', (stream: MediaStream) => {
|
||||
this.stream = stream;
|
||||
this.emit('stream', stream);
|
||||
});
|
||||
|
||||
this.conn.on('data', (data: any) => {
|
||||
this.emit('data', data);
|
||||
});
|
||||
|
||||
this.conn.on('open', () => {
|
||||
this.emit('open');
|
||||
});
|
||||
|
||||
this.conn.on('close', () => {
|
||||
this.emit('close');
|
||||
});
|
||||
|
||||
this.conn.on('error', (error: Error) => {
|
||||
this.emit('error', error);
|
||||
});
|
||||
}
|
||||
|
||||
send(data: any): void {
|
||||
this.conn.send(data);
|
||||
}
|
||||
|
||||
pushStream(stream: MediaStream): void {
|
||||
this.stream = stream;
|
||||
this.conn.sendStream(stream);
|
||||
}
|
||||
|
||||
getStream(): MediaStream | null {
|
||||
return this.stream;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.conn.close();
|
||||
}
|
||||
|
||||
on(event: string, callback: (...args: any[]) => void): void {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set());
|
||||
}
|
||||
this.listeners.get(event)!.add(callback);
|
||||
}
|
||||
|
||||
off(event: string, callback: (...args: any[]) => void): void {
|
||||
this.listeners.get(event)?.delete(callback);
|
||||
}
|
||||
|
||||
private emit(event: string, ...args: any[]): void {
|
||||
this.listeners.get(event)?.forEach(cb => cb(...args));
|
||||
}
|
||||
}
|
||||
|
||||
export class WebRTCVideoManager extends EventEmitter {
|
||||
private peer: Peer | null = null;
|
||||
private options: WebRTCVideoManagerOptions;
|
||||
private connections: Map<string, DataConnection> = new Map();
|
||||
private localStream: MediaStream | null = null;
|
||||
private state: PeerConnectionState = 'disconnected';
|
||||
private qualityMetrics: Map<string, ConnectionQuality> = new Map();
|
||||
|
||||
constructor(options: WebRTCVideoManagerOptions = {}) {
|
||||
super();
|
||||
this.options = {
|
||||
peerId: options.peerId,
|
||||
serverUrl: options.serverUrl || 'https://0.peerjs.com:443',
|
||||
audioEnabled: options.audioEnabled ?? true,
|
||||
videoEnabled: options.videoEnabled ?? true,
|
||||
turnServers: options.turnServers || [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
// Get local media stream
|
||||
await this.acquireLocalStream();
|
||||
|
||||
// Initialize PeerJS
|
||||
this.peer = new Peer(this.options.peerId, {
|
||||
host: new URL(this.options.serverUrl).hostname,
|
||||
port: new URL(this.options.serverUrl).port || 443,
|
||||
path: '/webrtc',
|
||||
secure: true,
|
||||
config: {
|
||||
iceServers: this.options.turnServers,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle incoming connections
|
||||
this.peer.on('connection', (conn: any) => {
|
||||
const dataConn = new DataConnection(this.peer!, conn);
|
||||
this.connections.set(conn.peer, dataConn);
|
||||
|
||||
// Send local stream to new peer
|
||||
if (this.localStream) {
|
||||
dataConn.pushStream(this.localStream);
|
||||
}
|
||||
|
||||
this.emit('peer:connected', conn.peer, this.localStream!);
|
||||
this.updateState('connected');
|
||||
});
|
||||
|
||||
// Handle peer errors
|
||||
this.peer.on('error', (error: any) => {
|
||||
this.emit('peer:error', this.peer!.id, error);
|
||||
this.updateState('error');
|
||||
});
|
||||
|
||||
// Handle connection open
|
||||
this.peer.on('open', (id: string) => {
|
||||
console.log('PeerJS initialized with ID:', id);
|
||||
this.updateState('connected');
|
||||
});
|
||||
|
||||
// Handle connection close
|
||||
this.peer.on('close', () => {
|
||||
this.updateState('disconnected');
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize WebRTC manager:', error);
|
||||
this.emit('peer:error', this.options.peerId || 'unknown', error as Error);
|
||||
this.updateState('error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async acquireLocalStream(): Promise<MediaStream> {
|
||||
try {
|
||||
const constraints: MediaStreamConstraints = {
|
||||
audio: this.options.audioEnabled,
|
||||
video: this.options.videoEnabled,
|
||||
};
|
||||
|
||||
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
this.emit('local:stream', this.localStream);
|
||||
|
||||
return this.localStream;
|
||||
} catch (error) {
|
||||
console.error('Failed to acquire local media stream:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
connectToPeer(peerId: string): DataConnection {
|
||||
if (!this.peer) {
|
||||
throw new Error('Peer not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
const conn = this.peer.connect(peerId, {
|
||||
metadata: {
|
||||
peerId: this.peer.id,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const dataConn = new DataConnection(this.peer, conn);
|
||||
this.connections.set(peerId, dataConn);
|
||||
|
||||
// Send local stream
|
||||
if (this.localStream) {
|
||||
dataConn.pushStream(this.localStream);
|
||||
}
|
||||
|
||||
// Monitor connection quality
|
||||
dataConn.on('open', () => {
|
||||
this.startQualityMonitoring(peerId, dataConn);
|
||||
});
|
||||
|
||||
dataConn.on('close', () => {
|
||||
this.qualityMetrics.delete(peerId);
|
||||
this.emit('peer:disconnected', peerId);
|
||||
});
|
||||
|
||||
return dataConn;
|
||||
}
|
||||
|
||||
getPeerConnection(peerId: string): DataConnection | undefined {
|
||||
return this.connections.get(peerId);
|
||||
}
|
||||
|
||||
getAllConnections(): Map<string, DataConnection> {
|
||||
return new Map(this.connections);
|
||||
}
|
||||
|
||||
disconnectFromPeer(peerId: string): void {
|
||||
const conn = this.connections.get(peerId);
|
||||
if (conn) {
|
||||
conn.close();
|
||||
this.connections.delete(peerId);
|
||||
this.emit('peer:disconnected', peerId);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectAll(): void {
|
||||
this.connections.forEach((conn, peerId) => {
|
||||
conn.close();
|
||||
this.emit('peer:disconnected', peerId);
|
||||
});
|
||||
this.connections.clear();
|
||||
}
|
||||
|
||||
getLocalStream(): MediaStream | null {
|
||||
return this.localStream;
|
||||
}
|
||||
|
||||
toggleAudio(): void {
|
||||
if (this.localStream) {
|
||||
const audioTrack = this.localStream.getAudioTracks()[0];
|
||||
if (audioTrack) {
|
||||
audioTrack.enabled = !audioTrack.enabled;
|
||||
this.options.audioEnabled = audioTrack.enabled;
|
||||
console.log(`Audio ${audioTrack.enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleVideo(): void {
|
||||
if (this.localStream) {
|
||||
const videoTrack = this.localStream.getVideoTracks()[0];
|
||||
if (videoTrack) {
|
||||
videoTrack.enabled = !videoTrack.enabled;
|
||||
this.options.videoEnabled = videoTrack.enabled;
|
||||
console.log(`Video ${videoTrack.enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isAudioEnabled(): boolean {
|
||||
if (this.localStream) {
|
||||
const audioTrack = this.localStream.getAudioTracks()[0];
|
||||
return audioTrack?.enabled ?? false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isVideoEnabled(): boolean {
|
||||
if (this.localStream) {
|
||||
const videoTrack = this.localStream.getVideoTracks()[0];
|
||||
return videoTrack?.enabled ?? false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getPeerId(): string | null {
|
||||
return this.peer?.id || null;
|
||||
}
|
||||
|
||||
getState(): PeerConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getConnectionQuality(peerId: string): ConnectionQuality {
|
||||
return this.qualityMetrics.get(peerId) || 'fair';
|
||||
}
|
||||
|
||||
private startQualityMonitoring(peerId: string, conn: DataConnection): void {
|
||||
let packetLoss = 0;
|
||||
let latencySamples: number[] = [];
|
||||
|
||||
const checkQuality = () => {
|
||||
// Simple quality estimation based on connection state
|
||||
if (conn) {
|
||||
const quality: ConnectionQuality = 'good';
|
||||
this.qualityMetrics.set(peerId, quality);
|
||||
this.emit('connection:quality', peerId, quality);
|
||||
}
|
||||
};
|
||||
|
||||
// Check quality every 5 seconds
|
||||
const interval = setInterval(checkQuality, 5000);
|
||||
checkQuality();
|
||||
|
||||
// Cleanup on close
|
||||
conn.on('close', () => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
}
|
||||
|
||||
private updateState(newState: PeerConnectionState): void {
|
||||
if (this.state !== newState) {
|
||||
this.state = newState;
|
||||
this.emit('state:changed', newState);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.disconnectAll();
|
||||
if (this.localStream) {
|
||||
this.localStream.getTracks().forEach(track => track.stop());
|
||||
this.localStream = null;
|
||||
}
|
||||
if (this.peer) {
|
||||
this.peer.destroy();
|
||||
this.peer = null;
|
||||
}
|
||||
this.updateState('disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
export default WebRTCVideoManager;
|
||||
Reference in New Issue
Block a user