/** * 自然写互动课堂PC端应用软件 V1.0 * WebRTC投屏模块 - 实现PC端屏幕内容投射到智慧黑板/电视大屏 * * 功能说明: * 1. WebRTC点对点连接建立(ICE候选收集、STUN/TURN穿透) * 2. 屏幕捕获与视频流编码(desktopCapturer API) * 3. 自适应码率控制(根据网络状况动态调整分辨率和帧率) * 4. 信令服务通信(通过WebSocket交换SDP和ICE候选) * 5. 多目标同时投屏(一个PC端可投射到多个大屏设备) * 6. 投屏区域选择(全屏/窗口/自定义区域) * 7. 音频同步传输(系统音频 + 麦克风输入混合) * 8. 投屏安全控制(PIN码配对,防止未授权投屏) */ import { EventEmitter } from 'events'; import crypto from 'crypto'; /* ========== 类型定义 ========== */ /** 投屏目标设备信息 */ interface CastTarget { deviceId: string; // 大屏设备唯一标识 deviceName: string; // 设备显示名称(如"教室1号黑板") deviceType: 'board' | 'tv'; // 设备类型:智慧黑板 / 电视 ipAddress: string; // 设备IP地址 port: number; // 信令端口 status: 'discovered' | 'connecting' | 'connected' | 'disconnected'; peerConnection: any; // RTCPeerConnection实例 lastPingTime: number; // 最后心跳时间 } /** 投屏配置参数 */ interface CastConfig { maxWidth: number; // 最大投屏分辨率宽度 maxHeight: number; // 最大投屏分辨率高度 maxFrameRate: number; // 最大帧率 minBitrate: number; // 最低码率(kbps) maxBitrate: number; // 最高码率(kbps) enableAudio: boolean; // 是否传输音频 captureMode: 'screen' | 'window' | 'region'; // 捕获模式 stunServers: string[]; // STUN服务器列表 turnServer: string; // TURN中继服务器地址 turnUsername: string; // TURN认证用户名 turnCredential: string; // TURN认证密码 signalServerUrl: string; // 信令服务器WebSocket地址 pinCode: string; // 投屏PIN码(4位数字) } /** 投屏质量统计 */ interface CastQualityStats { currentBitrate: number; // 当前码率(kbps) currentFps: number; // 当前帧率 packetLoss: number; // 丢包率(百分比) roundTripTime: number; // 往返延迟(毫秒) resolution: string; // 当前分辨率 encoderType: string; // 编码器类型 timestamp: number; } /** 信令消息格式 */ interface SignalMessage { type: 'offer' | 'answer' | 'candidate' | 'pin_verify' | 'cast_stop' | 'quality_adjust'; fromDeviceId: string; toDeviceId: string; payload: any; timestamp: number; signature: string; // HMAC-SHA256消息签名 } /* ========== 投屏管理器 ========== */ // 默认投屏配置 const DEFAULT_CAST_CONFIG: CastConfig = { maxWidth: 1920, maxHeight: 1080, maxFrameRate: 30, minBitrate: 500, maxBitrate: 4000, enableAudio: true, captureMode: 'screen', stunServers: ['stun:stun.writech.com:3478'], turnServer: 'turn:turn.writech.com:3478', turnUsername: '', turnCredential: '', signalServerUrl: 'wss://signal.writech.com/cast', pinCode: '' }; /** * 投屏管理器 - 管理WebRTC投屏的完整生命周期 * 支持同时向多个大屏设备投射内容 */ class ScreenCastManager extends EventEmitter { private config: CastConfig; private targets: Map = new Map(); // 投屏目标设备列表 private localStream: MediaStream | null = null; // 本地媒体流 private signalSocket: WebSocket | null = null; // 信令WebSocket连接 private localDeviceId: string; // 本机设备标识 private statsTimers: Map> = new Map(); private qualityHistory: CastQualityStats[] = []; // 质量统计历史 private isCapturing: boolean = false; private hmacKey: string; // 消息签名密钥 constructor(config?: Partial) { super(); this.config = { ...DEFAULT_CAST_CONFIG, ...config }; // 使用机器MAC地址+时间戳生成唯一设备标识 this.localDeviceId = `pc_${crypto.randomBytes(4).toString('hex')}`; this.hmacKey = crypto.randomBytes(16).toString('hex'); } /** * 初始化投屏管理器 * 建立信令服务器连接,准备接收设备发现消息 */ async initialize(): Promise { try { await this.connectSignalServer(); console.log('[ScreenCast] 投屏管理器初始化完成'); } catch (error) { console.error('[ScreenCast] 初始化失败:', error); throw error; } } /** * 连接信令服务器(通过WebSocket交换SDP和ICE候选) * 支持断线自动重连(指数退避策略) */ private async connectSignalServer(): Promise { return new Promise((resolve, reject) => { const url = `${this.config.signalServerUrl}?deviceId=${this.localDeviceId}&type=pc`; this.signalSocket = new WebSocket(url); this.signalSocket.onopen = () => { console.log('[ScreenCast] 信令服务器连接成功'); resolve(); }; this.signalSocket.onmessage = (event: MessageEvent) => { try { const message: SignalMessage = JSON.parse(event.data); this.handleSignalMessage(message); } catch (error) { console.error('[ScreenCast] 信令消息解析失败:', error); } }; this.signalSocket.onclose = () => { console.warn('[ScreenCast] 信令连接断开,5秒后重连'); setTimeout(() => this.connectSignalServer(), 5000); }; this.signalSocket.onerror = (error) => { console.error('[ScreenCast] 信令连接错误:', error); reject(error); }; }); } /** * 处理信令消息分发 * 根据消息类型执行不同的操作(SDP交换/ICE候选/PIN验证等) */ private handleSignalMessage(message: SignalMessage): void { // 验证消息签名(防止篡改) if (message.signature && !this.verifyMessageSignature(message)) { console.warn('[ScreenCast] 消息签名验证失败,丢弃:', message.type); return; } switch (message.type) { case 'answer': this.handleRemoteAnswer(message.fromDeviceId, message.payload); break; case 'candidate': this.handleRemoteCandidate(message.fromDeviceId, message.payload); break; case 'pin_verify': this.handlePinVerifyResult(message.fromDeviceId, message.payload); break; case 'quality_adjust': this.handleQualityAdjust(message.fromDeviceId, message.payload); break; case 'cast_stop': this.handleRemoteStop(message.fromDeviceId); break; default: console.warn('[ScreenCast] 未知信令类型:', message.type); } } /** * 开始屏幕捕获 - 使用Electron desktopCapturer API获取屏幕视频流 * 支持全屏、窗口、自定义区域三种捕获模式 */ async startCapture(sourceId?: string): Promise { if (this.isCapturing) { console.warn('[ScreenCast] 已在投屏中,请先停止当前投屏'); return; } try { // 通过Electron desktopCapturer获取可用的屏幕/窗口源 const { desktopCapturer } = require('electron'); const sources = await desktopCapturer.getSources({ types: this.config.captureMode === 'window' ? ['window'] : ['screen'], thumbnailSize: { width: 320, height: 180 } }); if (sources.length === 0) { throw new Error('未找到可用的屏幕源'); } // 选择屏幕源(默认使用第一个或指定的源) const selectedSource = sourceId ? sources.find((s: any) => s.id === sourceId) || sources[0] : sources[0]; // 配置视频约束参数 const videoConstraints: any = { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: selectedSource.id, maxWidth: this.config.maxWidth, maxHeight: this.config.maxHeight, maxFrameRate: this.config.maxFrameRate, minFrameRate: 15 } }; // 获取媒体流(视频 + 可选音频) const stream = await (navigator.mediaDevices as any).getUserMedia({ video: videoConstraints, audio: this.config.enableAudio ? { mandatory: { chromeMediaSource: 'desktop' } } : false }); this.localStream = stream; this.isCapturing = true; this.emit('captureStarted', { sourceId: selectedSource.id, name: selectedSource.name }); console.log('[ScreenCast] 屏幕捕获已启动:', selectedSource.name); } catch (error) { console.error('[ScreenCast] 屏幕捕获失败:', error); throw error; } } /** * 向指定大屏设备发起投屏连接 * 创建RTCPeerConnection,添加本地流,发送SDP Offer */ async castToDevice(deviceId: string, deviceName: string, ipAddress: string, port: number): Promise { if (!this.localStream) { throw new Error('请先启动屏幕捕获'); } // 创建投屏目标记录 const target: CastTarget = { deviceId, deviceName, deviceType: 'board', ipAddress, port, status: 'connecting', peerConnection: null, lastPingTime: Date.now() }; // 配置ICE服务器(STUN + TURN) const iceConfig: RTCConfiguration = { iceServers: [ { urls: this.config.stunServers }, { urls: this.config.turnServer, username: this.config.turnUsername, credential: this.config.turnCredential } ], iceCandidatePoolSize: 10 }; // 创建RTCPeerConnection const pc = new RTCPeerConnection(iceConfig); target.peerConnection = pc; // 添加本地媒体流的所有轨道 this.localStream.getTracks().forEach(track => { pc.addTrack(track, this.localStream!); }); // 配置视频编码参数(优先使用H.264 High Profile) const sender = pc.getSenders().find(s => s.track?.kind === 'video'); if (sender) { const params = sender.getParameters(); if (params.encodings && params.encodings.length > 0) { params.encodings[0].maxBitrate = this.config.maxBitrate * 1000; params.encodings[0].maxFramerate = this.config.maxFrameRate; await sender.setParameters(params); } } // 监听ICE候选事件,发送给对端 pc.onicecandidate = (event) => { if (event.candidate) { this.sendSignalMessage({ type: 'candidate', fromDeviceId: this.localDeviceId, toDeviceId: deviceId, payload: event.candidate.toJSON(), timestamp: Date.now(), signature: '' }); } }; // 监听连接状态变化 pc.onconnectionstatechange = () => { console.log(`[ScreenCast] 连接状态[${deviceName}]:`, pc.connectionState); switch (pc.connectionState) { case 'connected': target.status = 'connected'; this.startQualityMonitor(deviceId); this.emit('deviceConnected', { deviceId, deviceName }); break; case 'disconnected': case 'failed': target.status = 'disconnected'; this.stopQualityMonitor(deviceId); this.emit('deviceDisconnected', { deviceId, deviceName }); break; } }; // 创建并发送SDP Offer const offer = await pc.createOffer({ offerToReceiveAudio: false, offerToReceiveVideo: false }); await pc.setLocalDescription(offer); // 通过信令服务器发送Offer给大屏设备 this.sendSignalMessage({ type: 'offer', fromDeviceId: this.localDeviceId, toDeviceId: deviceId, payload: { sdp: offer.sdp, type: offer.type, pinCode: this.config.pinCode }, timestamp: Date.now(), signature: '' }); this.targets.set(deviceId, target); console.log(`[ScreenCast] 已向 ${deviceName} 发起投屏请求`); } /** 处理远端设备的SDP Answer */ private async handleRemoteAnswer(deviceId: string, payload: any): Promise { const target = this.targets.get(deviceId); if (!target || !target.peerConnection) return; try { const answer = new RTCSessionDescription(payload); await target.peerConnection.setRemoteDescription(answer); console.log(`[ScreenCast] 收到 ${target.deviceName} 的Answer`); } catch (error) { console.error(`[ScreenCast] 设置RemoteDescription失败:`, error); } } /** 处理远端ICE候选 */ private async handleRemoteCandidate(deviceId: string, payload: any): Promise { const target = this.targets.get(deviceId); if (!target || !target.peerConnection) return; try { const candidate = new RTCIceCandidate(payload); await target.peerConnection.addIceCandidate(candidate); } catch (error) { console.error('[ScreenCast] 添加ICE候选失败:', error); } } /** 处理PIN码验证结果 */ private handlePinVerifyResult(deviceId: string, payload: { verified: boolean }): void { if (!payload.verified) { console.warn(`[ScreenCast] 设备 ${deviceId} PIN码验证失败`); this.disconnectDevice(deviceId); this.emit('pinVerifyFailed', { deviceId }); } } /** 处理远端质量调整请求(大屏端网络差时要求降低码率) */ private handleQualityAdjust(deviceId: string, payload: { maxBitrate?: number; maxFps?: number }): void { const target = this.targets.get(deviceId); if (!target || !target.peerConnection) return; const sender = target.peerConnection.getSenders().find((s: any) => s.track?.kind === 'video'); if (sender) { const params = sender.getParameters(); if (params.encodings && params.encodings.length > 0) { if (payload.maxBitrate) { params.encodings[0].maxBitrate = payload.maxBitrate * 1000; } if (payload.maxFps) { params.encodings[0].maxFramerate = payload.maxFps; } sender.setParameters(params); console.log(`[ScreenCast] 已调整投屏质量: 码率=${payload.maxBitrate}kbps, 帧率=${payload.maxFps}fps`); } } } /** 处理远端停止投屏请求 */ private handleRemoteStop(deviceId: string): void { console.log(`[ScreenCast] 收到远端停止请求: ${deviceId}`); this.disconnectDevice(deviceId); } /** * 启动投屏质量监控 * 每3秒采集一次WebRTC连接统计信息 */ private startQualityMonitor(deviceId: string): void { const timer = setInterval(async () => { const target = this.targets.get(deviceId); if (!target || !target.peerConnection) { this.stopQualityMonitor(deviceId); return; } try { const stats = await target.peerConnection.getStats(); let qualityStats: CastQualityStats = { currentBitrate: 0, currentFps: 0, packetLoss: 0, roundTripTime: 0, resolution: '', encoderType: '', timestamp: Date.now() }; stats.forEach((report: any) => { if (report.type === 'outbound-rtp' && report.kind === 'video') { qualityStats.currentBitrate = Math.round((report.bytesSent * 8) / 1000); qualityStats.currentFps = report.framesPerSecond || 0; qualityStats.resolution = `${report.frameWidth}x${report.frameHeight}`; qualityStats.encoderType = report.encoderImplementation || 'unknown'; } if (report.type === 'candidate-pair' && report.state === 'succeeded') { qualityStats.roundTripTime = report.currentRoundTripTime * 1000; } if (report.type === 'remote-inbound-rtp') { qualityStats.packetLoss = report.fractionLost * 100; } }); // 保存统计历史(最多保留1000条) this.qualityHistory.push(qualityStats); if (this.qualityHistory.length > 1000) { this.qualityHistory.splice(0, this.qualityHistory.length - 1000); } // 自适应码率控制:丢包率过高时自动降低码率 if (qualityStats.packetLoss > 5) { const reducedBitrate = Math.max( this.config.minBitrate, qualityStats.currentBitrate * 0.7 ); this.adjustBitrate(deviceId, reducedBitrate); } else if (qualityStats.packetLoss < 1 && qualityStats.currentBitrate < this.config.maxBitrate) { // 网络状况良好时逐步提高码率 const increasedBitrate = Math.min( this.config.maxBitrate, qualityStats.currentBitrate * 1.1 ); this.adjustBitrate(deviceId, increasedBitrate); } this.emit('qualityUpdate', { deviceId, stats: qualityStats }); } catch (error) { console.error('[ScreenCast] 质量监控统计失败:', error); } }, 3000); this.statsTimers.set(deviceId, timer); } /** 停止质量监控 */ private stopQualityMonitor(deviceId: string): void { const timer = this.statsTimers.get(deviceId); if (timer) { clearInterval(timer); this.statsTimers.delete(deviceId); } } /** 动态调整视频码率 */ private adjustBitrate(deviceId: string, targetBitrate: number): void { const target = this.targets.get(deviceId); if (!target || !target.peerConnection) return; const sender = target.peerConnection.getSenders().find((s: any) => s.track?.kind === 'video'); if (sender) { const params = sender.getParameters(); if (params.encodings && params.encodings.length > 0) { params.encodings[0].maxBitrate = Math.round(targetBitrate * 1000); sender.setParameters(params).catch((e: Error) => { console.error('[ScreenCast] 码率调整失败:', e.message); }); } } } /** 断开指定设备的投屏连接 */ disconnectDevice(deviceId: string): void { const target = this.targets.get(deviceId); if (!target) return; // 关闭PeerConnection if (target.peerConnection) { target.peerConnection.close(); } // 停止质量监控 this.stopQualityMonitor(deviceId); // 通知对端 this.sendSignalMessage({ type: 'cast_stop', fromDeviceId: this.localDeviceId, toDeviceId: deviceId, payload: {}, timestamp: Date.now(), signature: '' }); this.targets.delete(deviceId); this.emit('deviceDisconnected', { deviceId, deviceName: target.deviceName }); console.log(`[ScreenCast] 已断开投屏: ${target.deviceName}`); } /** 停止所有投屏并释放资源 */ stopAllCasting(): void { // 断开所有投屏目标 for (const deviceId of this.targets.keys()) { this.disconnectDevice(deviceId); } // 停止屏幕捕获 if (this.localStream) { this.localStream.getTracks().forEach(track => track.stop()); this.localStream = null; } this.isCapturing = false; this.emit('allCastingStopped'); console.log('[ScreenCast] 所有投屏已停止'); } /** 发送信令消息(附加HMAC-SHA256签名) */ private sendSignalMessage(message: SignalMessage): void { // 生成消息签名,防止信令被篡改 const content = `${message.type}:${message.fromDeviceId}:${message.toDeviceId}:${message.timestamp}`; message.signature = crypto.createHmac('sha256', this.hmacKey).update(content).digest('hex'); if (this.signalSocket && this.signalSocket.readyState === WebSocket.OPEN) { this.signalSocket.send(JSON.stringify(message)); } else { console.warn('[ScreenCast] 信令连接不可用,消息发送失败'); } } /** 验证收到的信令消息签名 */ private verifyMessageSignature(message: SignalMessage): boolean { const content = `${message.type}:${message.fromDeviceId}:${message.toDeviceId}:${message.timestamp}`; const expected = crypto.createHmac('sha256', this.hmacKey).update(content).digest('hex'); return message.signature === expected; } /** 获取当前投屏状态汇总 */ getStatus(): { isCapturing: boolean; connectedDevices: number; targets: any[] } { const targetList = Array.from(this.targets.values()).map(t => ({ deviceId: t.deviceId, deviceName: t.deviceName, status: t.status, deviceType: t.deviceType })); return { isCapturing: this.isCapturing, connectedDevices: targetList.filter(t => t.status === 'connected').length, targets: targetList }; } /** 销毁投屏管理器,释放所有资源 */ destroy(): void { this.stopAllCasting(); if (this.signalSocket) { this.signalSocket.close(); this.signalSocket = null; } this.qualityHistory = []; this.removeAllListeners(); console.log('[ScreenCast] 投屏管理器已销毁'); } } export default ScreenCastManager;