diff --git a/server/webrtc/signaling-server.ts b/server/webrtc/signaling-server.ts new file mode 100644 index 000000000..97c8af070 --- /dev/null +++ b/server/webrtc/signaling-server.ts @@ -0,0 +1,259 @@ +/** + * WebRTC Signaling Server + * Reuses WebSocket infrastructure for WebRTC signaling + * Handles peer connection negotiation and ICE candidate exchange + */ + +import { WebSocketServer } from 'ws'; +import { Peer } from 'peerjs'; + +type SignalingMessage = { + type: 'offer' | 'answer' | 'ice-candidate' | 'disconnect'; + payload: RTCSessionDescriptionInit | RTCIceCandidate | { peerId: string }; + targetPeerId: string; +}; + +interface PeerConnection { + peer: Peer; + connections: Map; + iceCandidates: Map; +} + +export class WebRTCSignalingServer { + private wss: WebSocketServer; + private peers: Map = new Map(); + private port: number; + + constructor(port: number) { + this.port = port; + this.wss = new WebSocketServer({ port }); + this.initialize(); + } + + private initialize(): void { + console.log(`WebRTC Signaling Server starting on port ${this.port}`); + + this.wss.on('connection', (ws: any, req) => { + const url = new URL(req.url || '', `http://${req.headers.host}`); + const peerId = url.searchParams.get('peerId') || `peer-${Date.now()}-${Math.random()}`; + + console.log(`WebRTC peer connected: ${peerId}`); + + const peerConnection: PeerConnection = { + peer: new Peer(peerId, { + host: 'localhost', + port: this.port, + path: '/webrtc', + }), + connections: new Map(), + iceCandidates: new Map(), + }; + + this.peers.set(peerId, peerConnection); + + // Handle incoming signaling messages + ws.on('message', (data: Buffer) => { + try { + const message: SignalingMessage = JSON.parse(data.toString()); + this.handleSignalingMessage(peerId, message, ws); + } catch (error) { + console.error('Error parsing signaling message:', error); + ws.send(JSON.stringify({ type: 'error', payload: { message: 'Invalid message format' } })); + } + }); + + // Handle disconnection + ws.on('close', () => { + console.log(`WebRTC peer disconnected: ${peerId}`); + this.cleanupPeer(peerId); + }); + + // Send confirmation + ws.send(JSON.stringify({ + type: 'connected', + payload: { peerId } + })); + }); + + console.log(`WebRTC Signaling Server started on port ${this.port}`); + } + + private handleSignalingMessage( + sourcePeerId: string, + message: SignalingMessage, + ws: any + ): void { + const { type, payload, targetPeerId } = message; + const sourceConnection = this.peers.get(sourcePeerId); + + if (!sourceConnection) { + console.warn(`Source peer not found: ${sourcePeerId}`); + return; + } + + switch (type) { + case 'offer': + this.handleOffer(sourcePeerId, targetPeerId, payload as RTCSessionDescriptionInit, ws); + break; + + case 'answer': + this.handleAnswer(sourcePeerId, targetPeerId, payload as RTCSessionDescriptionInit); + break; + + case 'ice-candidate': + this.handleIceCandidate(sourcePeerId, targetPeerId, payload as RTCIceCandidate); + break; + + case 'disconnect': + this.disconnectPeer(sourcePeerId, targetPeerId); + break; + } + } + + private async handleOffer( + sourcePeerId: string, + targetPeerId: string, + offer: RTCSessionDescriptionInit, + ws: any + ): Promise { + console.log(`Offer received from ${sourcePeerId} to ${targetPeerId}`); + + const targetConnection = this.peers.get(targetPeerId); + if (!targetConnection) { + console.warn(`Target peer not found: ${targetPeerId}`); + ws.send(JSON.stringify({ + type: 'error', + payload: { message: `Target peer ${targetPeerId} not found` }, + })); + return; + } + + // Store the connection + if (!targetConnection.connections.has(sourcePeerId)) { + const conn = targetConnection.peer.connect(sourcePeerId); + targetConnection.connections.set(sourcePeerId, conn); + + // Handle connection events + conn.on('open', () => { + console.log(`Connection opened: ${targetPeerId} <-> ${sourcePeerId}`); + }); + + conn.on('data', (data: any) => { + // Handle data channel messages + console.log(`Data received: ${targetPeerId} from ${sourcePeerId}`, data); + }); + + conn.on('stream', (stream: MediaStream) => { + console.log(`Media stream received: ${targetPeerId} from ${sourcePeerId}`); + }); + + // Send accumulated ICE candidates + const accumulatedCandidates = targetConnection.iceCandidates.get(sourcePeerId) || []; + accumulatedCandidates.forEach(candidate => { + conn.send(JSON.stringify({ + type: 'ice-candidate', + payload: candidate, + targetPeerId, + })); + }); + } + + // Forward offer to target peer + targetConnection.connections.get(sourcePeerId).send(JSON.stringify({ + type: 'offer', + payload: offer, + targetPeerId: targetPeerId, + })); + } + + private handleAnswer( + sourcePeerId: string, + targetPeerId: string, + answer: RTCSessionDescriptionInit + ): void { + console.log(`Answer received from ${sourcePeerId} to ${targetPeerId}`); + + const targetConnection = this.peers.get(targetPeerId); + if (targetConnection?.connections.has(sourcePeerId)) { + targetConnection.connections.get(sourcePeerId).send(JSON.stringify({ + type: 'answer', + payload: answer, + targetPeerId: targetPeerId, + })); + } + } + + private handleIceCandidate( + sourcePeerId: string, + targetPeerId: string, + candidate: RTCIceCandidate + ): void { + const targetConnection = this.peers.get(targetPeerId); + + if (!targetConnection) { + // Store candidate for later delivery + if (!targetConnection?.iceCandidates.has(sourcePeerId)) { + targetConnection?.iceCandidates.set(sourcePeerId, []); + } + targetConnection?.iceCandidates.get(sourcePeerId)!.push(candidate); + return; + } + + // Forward ICE candidate to target peer + if (targetConnection.connections.has(sourcePeerId)) { + targetConnection.connections.get(sourcePeerId).send(JSON.stringify({ + type: 'ice-candidate', + payload: candidate, + targetPeerId: targetPeerId, + })); + } + } + + private disconnectPeer(sourcePeerId: string, targetPeerId: string): void { + const sourceConnection = this.peers.get(sourcePeerId); + if (sourceConnection?.connections.has(targetPeerId)) { + sourceConnection.connections.get(targetPeerId).close(); + sourceConnection.connections.delete(targetPeerId); + console.log(`Connection closed: ${sourcePeerId} <-> ${targetPeerId}`); + } + } + + private cleanupPeer(peerId: string): void { + const peerConnection = this.peers.get(peerId); + if (peerConnection) { + // Close all connections + peerConnection.connections.forEach((conn, connectedPeerId) => { + conn.close(); + console.log(`Cleaned up connection: ${peerId} <-> ${connectedPeerId}`); + }); + + // Destroy PeerJS instance + peerConnection.peer.destroy(); + + // Remove from registry + this.peers.delete(peerId); + } + } + + getPeerCount(): number { + return this.peers.size; + } + + getPeers(): string[] { + return Array.from(this.peers.keys()); + } + + getPeerConnections(peerId: string): string[] { + const peerConnection = this.peers.get(peerId); + if (!peerConnection) return []; + return Array.from(peerConnection.connections.keys()); + } + + close(): void { + this.peers.forEach((_, peerId) => this.cleanupPeer(peerId)); + this.wss.close(); + console.log('WebRTC Signaling Server closed'); + } +} + +export default WebRTCSignalingServer; diff --git a/src/components/collaboration/collaborator-list.test.tsx b/src/components/collaboration/collaborator-list.test.tsx new file mode 100644 index 000000000..028f7ee67 --- /dev/null +++ b/src/components/collaboration/collaborator-list.test.tsx @@ -0,0 +1,111 @@ +/** + * Collaborator List Component Tests + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { CollaboratorList } from './collaborator-list'; +import { UserPresence } from '../../lib/collaboration/presence-manager'; + +describe('CollaboratorList', () => { + const mockUsers: UserPresence[] = [ + { + userId: 'user-1', + name: 'Alice', + color: '#ef4444', + cursorPosition: 120, + selectionStart: 120, + selectionEnd: 135, + editingContext: 'scene:scene-1', + lastActivity: new Date(), + status: 'active', + }, + { + userId: 'user-2', + name: 'Bob', + color: '#3b82f6', + cursorPosition: 250, + selectionStart: null, + selectionEnd: null, + editingContext: 'character:char-1', + lastActivity: new Date(), + status: 'active', + }, + { + userId: 'user-3', + name: 'Charlie', + color: '#22c55e', + cursorPosition: null, + selectionStart: null, + selectionEnd: null, + editingContext: null, + lastActivity: new Date(), + status: 'idle', + }, + ]; + + const mockLocalPresence: UserPresence = { + userId: 'user-local', + name: 'You', + color: '#eab308', + cursorPosition: 100, + selectionStart: 95, + selectionEnd: 100, + editingContext: 'scene:scene-1', + lastActivity: new Date(), + status: 'active', + }; + + it('renders the collaborator list header', () => { + const wrapper = document.createElement('div'); + const getConnectedUsers = () => mockUsers; + const getLocalPresence = () => mockLocalPresence; + + const component = new CollaboratorList({ + getConnectedUsers, + getLocalPresence, + }); + + // Component should render without errors + expect(component).toBeTruthy(); + }); + + it('displays all connected users', () => { + const getConnectedUsers = () => mockUsers; + const getLocalPresence = () => mockLocalPresence; + + const component = new CollaboratorList({ + getConnectedUsers, + getLocalPresence, + }); + + // Should show 4 users (3 remote + 1 local) + expect(component).toBeTruthy(); + }); + + it('shows correct status indicators', () => { + const activeUser = mockUsers.find(u => u.userId === 'user-1'); + const idleUser = mockUsers.find(u => u.userId === 'user-3'); + + expect(activeUser?.status).toBe('active'); + expect(idleUser?.status).toBe('idle'); + }); + + it('displays editing context correctly', () => { + const editingUser = mockUsers.find(u => u.editingContext === 'scene:scene-1'); + expect(editingUser).toBeTruthy(); + expect(editingUser?.editingContext).toBe('scene:scene-1'); + }); + + it('handles null cursor positions', () => { + const userWithNoCursor = mockUsers.find(u => u.cursorPosition === null); + expect(userWithNoCursor).toBeTruthy(); + expect(userWithNoCursor?.cursorPosition).toBeNull(); + }); + + it('assigns correct user colors', () => { + const userColors = mockUsers.map(u => u.color); + expect(userColors).toContain('#ef4444'); // Alice + expect(userColors).toContain('#3b82f6'); // Bob + expect(userColors).toContain('#22c55e'); // Charlie + }); +}); diff --git a/src/components/collaboration/collaborator-list.tsx b/src/components/collaboration/collaborator-list.tsx new file mode 100644 index 000000000..e66b5eed2 --- /dev/null +++ b/src/components/collaboration/collaborator-list.tsx @@ -0,0 +1,273 @@ +/** + * Collaborator List Component + * Displays connected users with their presence state (cursor position, editing context) + */ + +import { Component, createSignal, onMount, For } from 'solid-js'; +import { UserPresence } from '../../lib/collaboration/presence-manager'; + +export interface CollaboratorListProps { + getConnectedUsers: () => UserPresence[]; + getLocalPresence: () => UserPresence; + className?: string; +} + +export const CollaboratorList: Component = (props) => { + const [users, setUsers] = createSignal([]); + const [localPresence, setLocalPresence] = createSignal(null); + + let listRef: HTMLUListElement | undefined; + + onMount(() => { + // Initial load + setUsers(props.getConnectedUsers()); + setLocalPresence(props.getLocalPresence()); + + // Poll for updates (in production, use subscription pattern) + const interval = setInterval(() => { + const currentUsers = props.getConnectedUsers(); + const currentLocal = props.getLocalPresence(); + + if ( + JSON.stringify(users().map(u => ({ userId: u.userId, status: u.status }))) !== + JSON.stringify(currentUsers.map(u => ({ userId: u.userId, status: u.status }))) + ) { + setUsers(currentUsers); + } + + if ( + JSON.stringify(localPresence()?.userId) !== + JSON.stringify(currentLocal?.userId) + ) { + setLocalPresence(currentLocal); + } + }, 1000); + + return () => { + clearInterval(interval); + }; + }); + + const getStatusIcon = (status: UserPresence['status']): string => { + switch (status) { + case 'active': + return '●'; + case 'idle': + return '○'; + case 'away': + return '◌'; + } + }; + + const getStatusColor = (status: UserPresence['status']): string => { + switch (status) { + case 'active': + return '#22c55e'; // green + case 'idle': + return '#eab308'; // yellow + case 'away': + return '#94a3b8'; // gray + } + }; + + const formatEditingContext = (context: string | null): string => { + if (!context) return 'Not editing'; + // Parse context string (e.g., "scene:scene-1" or "character:char-1") + const parts = context.split(':'); + if (parts.length === 2) { + const [type, id] = parts; + return `${type.charAt(0).toUpperCase() + type.slice(1)}: ${id}`; + } + return context; + }; + + const formatCursorPosition = (position: number | null): string => { + if (position === null) return '-'; + // Convert to line:column format (simplified - assumes 80 chars per line) + const line = Math.floor(position / 80) + 1; + const column = (position % 80) + 1; + return `${line}:${column}`; + }; + + return ( +
+

+ Collaborators ({users().length + 1}) +

+ +
    + + {(user) => { + const isLocal = user.userId === localPresence()?.userId; + + return ( +
  • + {/* Status indicator */} + + + + {user.status} + + + + {/* User info */} +
    +
    + + {user.name} + {isLocal && ( + + You + + )} +
    + + {/* Editing context */} +
    + {user.editingContext ? ( + + Editing: {formatEditingContext(user.editingContext)} + + ) : ( + Browsing + )} +
    + + {/* Cursor position */} + {user.cursorPosition !== null && ( +
    + Cursor: {formatCursorPosition(user.cursorPosition)} + {user.selectionStart !== null && user.selectionEnd !== null && ( + + {' '}({user.selectionEnd - user.selectionStart} selected) + + )} +
    + )} +
    +
  • + ); + }} +
    +
+ +
+ {users().length} remote collaborators connected +
+
+ ); +}; + +export default CollaboratorList; diff --git a/src/components/collaboration/connection-status-indicator.tsx b/src/components/collaboration/connection-status-indicator.tsx new file mode 100644 index 000000000..ef770026b --- /dev/null +++ b/src/components/collaboration/connection-status-indicator.tsx @@ -0,0 +1,112 @@ +/** + * Connection Status Indicator + * Displays the current WebSocket connection state to the user + */ + +import { Component, createEffect, onMount, createSignal } from 'solid-js'; +import { ConnectionStatus } from '../../lib/collaboration/websocket-connection'; + +export interface ConnectionStatusIndicatorProps { + getStatus: () => ConnectionStatus; + className?: string; +} + +export const ConnectionStatusIndicator: Component = (props) => { + const [status, setStatus] = createSignal('disconnected'); + const [lastUpdate, setLastUpdate] = createSignal(new Date()); + + let indicatorRef: HTMLSpanElement | undefined; + + onMount(() => { + // Get initial status + setStatus(props.getStatus()); + setLastUpdate(new Date()); + + // Create a simple polling mechanism to check status + // In production, this would use a subscription pattern + const interval = setInterval(() => { + const currentStatus = props.getStatus(); + if (currentStatus !== status()) { + setStatus(currentStatus); + setLastUpdate(new Date()); + } + }, 500); + + return () => { + clearInterval(interval); + }; + }); + + const getStatusColor = (s: ConnectionStatus): string => { + switch (s) { + case 'connected': + return '#22c55e'; // green + case 'connecting': + return '#eab308'; // yellow + case 'reconnecting': + return '#f97316'; // orange + case 'disconnected': + default: + return '#ef4444'; // red + } + }; + + const getStatusLabel = (s: ConnectionStatus): string => { + switch (s) { + case 'connected': + return 'Connected'; + case 'connecting': + return 'Connecting...'; + case 'reconnecting': + return 'Reconnecting...'; + case 'disconnected': + default: + return 'Disconnected'; + } + }; + + return ( +
+ + {getStatusLabel(status())} + {status() === 'connected' && ( + + {lastUpdate().toLocaleTimeString()} + + )} + +
+ ); +}; + +export default ConnectionStatusIndicator; diff --git a/src/components/collaboration/editing-indicator.tsx b/src/components/collaboration/editing-indicator.tsx new file mode 100644 index 000000000..8207a8c90 --- /dev/null +++ b/src/components/collaboration/editing-indicator.tsx @@ -0,0 +1,187 @@ +/** + * User Editing Indicator Component + * Shows visual indicators for which users are currently editing specific sections + */ + +import { Component, For, createSignal, onMount } from 'solid-js'; +import { UserPresence } from '../../lib/collaboration/presence-manager'; + +export interface EditingIndicatorProps { + getConnectedUsers: () => UserPresence[]; + className?: string; +} + +export const EditingIndicator: Component = (props) => { + const [editingUsers, setEditingUsers] = createSignal([]); + + onMount(() => { + updateEditingUsers(); + + // Poll for updates + const interval = setInterval(() => { + updateEditingUsers(); + }, 500); + + return () => { + clearInterval(interval); + }; + }); + + const updateEditingUsers = () => { + const users = props.getConnectedUsers(); + const editing = users.filter(user => + user.status === 'active' && + user.editingContext !== null + ); + setEditingUsers(editing); + }; + + const parseEditingContext = (context: string) => { + const parts = context.split(':'); + if (parts.length === 2) { + return { type: parts[0], id: parts[1] }; + } + return { type: 'unknown', id: context }; + }; + + const getContextLabel = (context: string): string => { + const { type, id } = parseEditingContext(context); + + const typeLabels: Record = { + scene: 'Scene', + character: 'Character', + dialogue: 'Dialogue', + action: 'Action', + transition: 'Transition', + slugline: 'Slugline', + }; + + return `${typeLabels[type] || type}: ${id}`; + }; + + return ( +
+
+ + Active Editors +
+ + + {(user) => { + const { type, id } = parseEditingContext(user.editingContext!); + + return ( +
+ +
+ + {user.name} + + + {getContextLabel(user.editingContext!)} + +
+ + Editing + +
+ ); + }} +
+ + {editingUsers().length === 0 && ( +
+ No one is currently editing +
+ )} + + +
+ ); +}; + +export default EditingIndicator; diff --git a/src/components/collaboration/index.ts b/src/components/collaboration/index.ts new file mode 100644 index 000000000..7c6ef9866 --- /dev/null +++ b/src/components/collaboration/index.ts @@ -0,0 +1,16 @@ +/** + * Collaboration Components + * Re-export all collaboration-related components + */ + +export { ConnectionStatusIndicator } from './connection-status-indicator'; +export type { ConnectionStatusIndicatorProps } from './connection-status-indicator'; + +export { CollaboratorList } from './collaborator-list'; +export type { CollaboratorListProps } from './collaborator-list'; + +export { RemoteCursorOverlay } from './remote-cursor-overlay'; +export type { RemoteCursorOverlayProps, RemoteCursor } from './remote-cursor-overlay'; + +export { EditingIndicator } from './editing-indicator'; +export type { EditingIndicatorProps } from './editing-indicator'; diff --git a/src/components/collaboration/remote-cursor-overlay.tsx b/src/components/collaboration/remote-cursor-overlay.tsx new file mode 100644 index 000000000..fca77c556 --- /dev/null +++ b/src/components/collaboration/remote-cursor-overlay.tsx @@ -0,0 +1,184 @@ +/** + * Remote Cursor Component + * Renders remote user cursors in the editor with their color and name + */ + +import { Component, For, createSignal, onMount } from 'solid-js'; +import { UserPresence } from '../../lib/collaboration/presence-manager'; + +export interface RemoteCursor { + userId: string; + name: string; + color: string; + position: number; + selectionStart?: number | null; + selectionEnd?: number | null; +} + +export interface RemoteCursorOverlayProps { + getConnectedUsers: () => UserPresence[]; + editorContainerRef: HTMLElement | undefined; + className?: string; +} + +export const RemoteCursorOverlay: Component = (props) => { + const [cursors, setCursors] = createSignal([]); + const [editorWidth, setEditorWidth] = createSignal(0); + const [editorHeight, setEditorHeight] = createSignal(0); + + // Character width for cursor positioning (monospace font assumption) + const CHAR_WIDTH = 8.4; // Approximate width of monospace character at 14px + const LINE_HEIGHT = 20; + + onMount(() => { + // Initial load + updateCursors(); + updateEditorDimensions(); + + // Poll for cursor updates + const interval = setInterval(() => { + updateCursors(); + updateEditorDimensions(); + }, 100); + + // Listen for resize + if (props.editorContainerRef) { + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + setEditorWidth(entry.contentRect.width); + setEditorHeight(entry.contentRect.height); + } + }); + resizeObserver.observe(props.editorContainerRef); + + return () => { + resizeObserver.disconnect(); + clearInterval(interval); + }; + } + + return () => { + clearInterval(interval); + }; + }); + + const updateCursors = () => { + const users = props.getConnectedUsers(); + const newCursors: RemoteCursor[] = users + .filter(user => user.cursorPosition !== null) + .map(user => ({ + userId: user.userId, + name: user.name, + color: user.color, + position: user.cursorPosition!, + selectionStart: user.selectionStart, + selectionEnd: user.selectionEnd, + })); + + setCursors(newCursors); + }; + + const updateEditorDimensions = () => { + if (props.editorContainerRef) { + setEditorWidth(props.editorContainerRef.clientWidth); + setEditorHeight(props.editorContainerRef.clientHeight); + } + }; + + /** + * Convert character position to pixel coordinates + * Assumes monospace font and calculates line/column from position + */ + const positionToCoordinates = (position: number) => { + const charsPerLine = Math.floor(editorWidth() / CHAR_WIDTH); + const line = Math.floor(position / charsPerLine); + const column = position % charsPerLine; + + return { + x: column * CHAR_WIDTH, + y: line * LINE_HEIGHT, + }; + }; + + return ( +
+ + {(cursor) => { + const coords = positionToCoordinates(cursor.position); + + return ( + <> + {/* Cursor line */} +
+ + {/* Cursor label */} +
+ {cursor.name} +
+ + {/* Selection highlight (if any) */} + {cursor.selectionStart !== null && cursor.selectionEnd !== null && cursor.selectionStart !== cursor.selectionEnd && ( + (() => { + const startCoords = positionToCoordinates(cursor.selectionStart); + const endCoords = positionToCoordinates(cursor.selectionEnd); + const sameLine = Math.floor(startCoords.y / LINE_HEIGHT) === Math.floor(endCoords.y / LINE_HEIGHT); + + return ( +
+ ); + })() + )} + + ); + }} + +
+ ); +}; + +export default RemoteCursorOverlay; diff --git a/src/lib/collaboration/presence-manager.ts b/src/lib/collaboration/presence-manager.ts new file mode 100644 index 000000000..449c27295 --- /dev/null +++ b/src/lib/collaboration/presence-manager.ts @@ -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; + timestamp: number; +} + +/** + * User join event + */ +export interface UserJoinMessage { + type: 'presence:join'; + userId: string; + presence: Omit; + 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>; + 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) => void; +export type OnUserLeave = (userId: string) => void; +export type OnPresenceUpdate = (userId: string, presence: UserPresence) => void; +export type OnPresenceState = (users: Record) => 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 = new Map(); + + // Local user's current state + private localPresence: UserPresence; + + // Timers + private idleTimer: ReturnType | null = null; + private broadcastTimer: ReturnType | null = null; + + // Event callbacks + private onUserJoinCallbacks: Set = new Set(); + private onUserLeaveCallbacks: Set = new Set(); + private onPresenceUpdateCallbacks: Set = new Set(); + private onPresenceStateCallbacks: Set = 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 }) => { + 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): 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 }) => { + 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 +]; + + + +/home/mike/code/FrenoCorp/src/lib/collaboration/presence-manager.ts \ No newline at end of file diff --git a/src/lib/video/webrtc-video-manager.ts b/src/lib/video/webrtc-video-manager.ts new file mode 100644 index 000000000..49ed20f54 --- /dev/null +++ b/src/lib/video/webrtc-video-manager.ts @@ -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 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 = new Map(); + private localStream: MediaStream | null = null; + private state: PeerConnectionState = 'disconnected'; + private qualityMetrics: Map = 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 { + 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 { + 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 { + 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;