software copyright

This commit is contained in:
jiahong
2026-03-22 15:24:40 +08:00
parent e303bb868a
commit 60f336e345
155 changed files with 127262 additions and 0 deletions
@@ -0,0 +1,606 @@
/**
* 自然写互动课堂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;