Files
2026-03-22 15:24:40 +08:00

607 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 自然写互动课堂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<string, CastTarget> = new Map(); // 投屏目标设备列表
private localStream: MediaStream | null = null; // 本地媒体流
private signalSocket: WebSocket | null = null; // 信令WebSocket连接
private localDeviceId: string; // 本机设备标识
private statsTimers: Map<string, ReturnType<typeof setInterval>> = new Map();
private qualityHistory: CastQualityStats[] = []; // 质量统计历史
private isCapturing: boolean = false;
private hmacKey: string; // 消息签名密钥
constructor(config?: Partial<CastConfig>) {
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<void> {
try {
await this.connectSignalServer();
console.log('[ScreenCast] 投屏管理器初始化完成');
} catch (error) {
console.error('[ScreenCast] 初始化失败:', error);
throw error;
}
}
/**
* 连接信令服务器(通过WebSocket交换SDP和ICE候选)
* 支持断线自动重连(指数退避策略)
*/
private async connectSignalServer(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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;