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:
2026-04-23 06:37:21 -04:00
parent ef1b15c9ea
commit 1c74a082e5
9 changed files with 2074 additions and 0 deletions

View 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;