yjz-web-sdk 1.0.10 → 1.0.11-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -0
- package/lib/ScreenControlUtil-D4-BTCo9.js +5230 -0
- package/lib/components/RemotePlayer/index.vue.d.ts +1 -73
- package/lib/composables/useCursorStyle.d.ts +1 -1
- package/lib/composables/useKeyboardControl.d.ts +5 -1
- package/lib/composables/useMouseTouchControl.d.ts +5 -4
- package/lib/composables/useRemoteVideo.d.ts +8 -25
- package/lib/composables/useResizeObserver.d.ts +1 -1
- package/lib/core/WebRTCSdk.d.ts +3 -0
- package/lib/core/data/WebRtcError.d.ts +3 -3
- package/lib/core/data/WebrtcDataType.d.ts +1 -11
- package/lib/core/groupctrl/SdkController.d.ts +2 -2
- package/lib/core/rtc/WebRTCClient.d.ts +13 -3
- package/lib/core/rtc/WebRTCConfig.d.ts +3 -1
- package/lib/core/rtc/WebRtcNegotiate.d.ts +3 -3
- package/lib/core/signal/SignalingClient.d.ts +1 -1
- package/lib/core/util/KeyCodeUtil.d.ts +6 -0
- package/lib/core/util/TurnTestUtil.d.ts +4 -4
- package/lib/index.d.ts +3 -3
- package/lib/uni/KeyboardControl.d.ts +53 -0
- package/lib/uni/Logger.d.ts +13 -0
- package/lib/uni/MouseTouchControl.d.ts +56 -0
- package/lib/uni/RemoteCanvasController.d.ts +11 -0
- package/lib/uni/RemoteController.d.ts +23 -0
- package/lib/uni/RemoteVideoController.d.ts +38 -0
- package/lib/uni/WebRTCWrapper.d.ts +57 -0
- package/lib/uni/constants.d.ts +42 -0
- package/lib/uni/index.d.ts +110 -0
- package/lib/{components/RemotePlayer → uni}/type.d.ts +1 -0
- package/lib/uni-sdk.js +1263 -0
- package/lib/yjz-web-sdk.js +312 -5955
- package/package.json +10 -20
- package/lib/core/data/TurnType.d.ts +0 -21
- package/lib/core/util/MapCache.d.ts +0 -20
- package/lib/render/Canvas2DRenderer.d.ts +0 -10
- package/lib/render/WebGLRenderer.d.ts +0 -16
- package/lib/render/WebGPURenderer.d.ts +0 -18
- package/lib/types/index.d.ts +0 -13
- package/lib/util/WasmUtil.d.ts +0 -17
- package/lib/worker/worker.d.ts +0 -1
- package/src/assets/icon/circle.svg +0 -1
- package/src/assets/icon/triangle.svg +0 -1
- package/src/assets/wasm/h264-atomic.wasm +0 -0
- package/src/assets/wasm/h264-simd.wasm +0 -0
- package/src/components/RemotePlayer/index.vue +0 -170
- package/src/components/RemotePlayer/type.ts +0 -11
- package/src/composables/useCursorStyle.ts +0 -15
- package/src/composables/useKeyboardControl.ts +0 -32
- package/src/composables/useMouseTouchControl.ts +0 -158
- package/src/composables/useRemoteVideo.ts +0 -248
- package/src/composables/useResizeObserver.ts +0 -27
- package/src/core/WebRTCSdk.ts +0 -561
- package/src/core/data/MessageType.ts +0 -70
- package/src/core/data/TurnType.ts +0 -25
- package/src/core/data/WebRtcError.ts +0 -93
- package/src/core/data/WebrtcDataType.ts +0 -354
- package/src/core/groupctrl/GroupCtrlSocketManager.ts +0 -94
- package/src/core/groupctrl/SdkController.ts +0 -96
- package/src/core/rtc/WebRTCClient.ts +0 -862
- package/src/core/rtc/WebRTCConfig.ts +0 -86
- package/src/core/rtc/WebRtcNegotiate.ts +0 -164
- package/src/core/signal/SignalingClient.ts +0 -221
- package/src/core/util/FileTypeUtils.ts +0 -75
- package/src/core/util/KeyCodeUtil.ts +0 -162
- package/src/core/util/Logger.ts +0 -83
- package/src/core/util/MapCache.ts +0 -135
- package/src/core/util/ScreenControlUtil.ts +0 -174
- package/src/core/util/TurnTestUtil.ts +0 -123
- package/src/env.d.ts +0 -30
- package/src/index.ts +0 -61
- package/src/render/Canvas2DRenderer.ts +0 -38
- package/src/render/WebGLRenderer.ts +0 -150
- package/src/render/WebGPURenderer.ts +0 -194
- package/src/types/index.ts +0 -15
- package/src/types/webgpu.d.ts +0 -1158
- package/src/util/WasmUtil.ts +0 -291
- package/src/worker/worker.ts +0 -292
|
@@ -1,862 +0,0 @@
|
|
|
1
|
-
import {EventEmitter} from 'eventemitter3';
|
|
2
|
-
|
|
3
|
-
import 'webrtc-adapter';
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
addIceCandidate,
|
|
7
|
-
configPeerConnection,
|
|
8
|
-
createOffer,
|
|
9
|
-
createPeerConnection,
|
|
10
|
-
setRemoteDescriptionWithHandleAnswer,
|
|
11
|
-
setRemoteDescriptionWithHandleOffer
|
|
12
|
-
} from './WebRtcNegotiate';
|
|
13
|
-
import type {WebRTCConfig} from "./WebRTCConfig";
|
|
14
|
-
import {CameraFailCode, createCameraError, createWebRtcError, EmitType, FailCode} from "../data/WebRtcError";
|
|
15
|
-
import {
|
|
16
|
-
ChannelData,
|
|
17
|
-
ChannelDataType,
|
|
18
|
-
type CloudStatusPayload,
|
|
19
|
-
ContainerDirection,
|
|
20
|
-
type ScreenStats,
|
|
21
|
-
StreamRotation,
|
|
22
|
-
} from "../data/WebrtcDataType";
|
|
23
|
-
import {FileTypeUtils} from "../util/FileTypeUtils";
|
|
24
|
-
import {Logger} from "../util/Logger";
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
export class WebRTCClient extends EventEmitter {
|
|
28
|
-
private readonly config: WebRTCConfig;
|
|
29
|
-
private peerConnection: RTCPeerConnection | null = null;
|
|
30
|
-
private localStream: MediaStream | null = null;
|
|
31
|
-
private rotatedStream: MediaStream | null = null;
|
|
32
|
-
private isPushingStream = false;
|
|
33
|
-
private isPushingLocalStream = false;
|
|
34
|
-
private dataChannel: RTCDataChannel | null = null;
|
|
35
|
-
private videoDataChannel: RTCDataChannel | null = null;
|
|
36
|
-
private statsTimer: number | undefined = undefined;
|
|
37
|
-
private lastReportTime = 0;
|
|
38
|
-
private lastBytesReceived = 0;
|
|
39
|
-
private lastPacketsLost = 0;
|
|
40
|
-
private lastPacketsReceived = 0;
|
|
41
|
-
private lostPacketCount = 0;
|
|
42
|
-
private maxLostRate = 0;
|
|
43
|
-
private lastSecondDecodedCount = 0;
|
|
44
|
-
private fileVideo?: HTMLVideoElement;
|
|
45
|
-
|
|
46
|
-
private canvas?: HTMLCanvasElement;
|
|
47
|
-
private canvasStream: MediaStream | null = null;
|
|
48
|
-
private rafId: number = 0;
|
|
49
|
-
private currentMedia: HTMLVideoElement | HTMLImageElement | null = null;
|
|
50
|
-
private fileImage?: HTMLImageElement;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
private isFirst = true;
|
|
54
|
-
|
|
55
|
-
constructor(config: WebRTCConfig) {
|
|
56
|
-
super();
|
|
57
|
-
this.config = config;
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
public async startPush(useBackCamera: boolean = true) {
|
|
62
|
-
if(this.isPushingLocalStream) {
|
|
63
|
-
this.stopLocal()
|
|
64
|
-
}
|
|
65
|
-
try {
|
|
66
|
-
this.isPushingStream = true;
|
|
67
|
-
await this.readyCapture(useBackCamera);
|
|
68
|
-
} catch (err) {
|
|
69
|
-
this.isPushingStream = false;
|
|
70
|
-
let errorMessage: string;
|
|
71
|
-
if (err instanceof Error) {
|
|
72
|
-
errorMessage = err.message; // ✅ 只取 message
|
|
73
|
-
} else {
|
|
74
|
-
errorMessage = String(err); // 兜底,防止 err 不是 Error 类型
|
|
75
|
-
}
|
|
76
|
-
this.emit(
|
|
77
|
-
EmitType.cameraError,
|
|
78
|
-
createCameraError(CameraFailCode.CAMERA_STREAM_FAIL, errorMessage)
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
public handleOffer(offerSdp: string) {
|
|
84
|
-
this.resetPeerConnection();
|
|
85
|
-
setRemoteDescriptionWithHandleOffer(this.peerConnection!, offerSdp, (sdp: string) => {
|
|
86
|
-
this.emit(EmitType.sendAnswer, sdp)
|
|
87
|
-
}, err => this.emit(EmitType.webrtcError, err));
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
public handleAnswer(answerSdp: string) {
|
|
91
|
-
setRemoteDescriptionWithHandleAnswer(this.peerConnection!, answerSdp ?? '', err => this.emit(EmitType.webrtcError, err));
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
public handleIceCandidate(candidate: RTCIceCandidateInit) {
|
|
95
|
-
addIceCandidate(this.peerConnection!, candidate, err => this.emit(EmitType.webrtcError, err));
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
public sendChannelData(type: ChannelDataType, data: any) {
|
|
99
|
-
try{
|
|
100
|
-
let channelData:ChannelData | null = null;
|
|
101
|
-
switch (type) {
|
|
102
|
-
case ChannelDataType.ClickData:
|
|
103
|
-
channelData = ChannelData.click(data);
|
|
104
|
-
break;
|
|
105
|
-
case ChannelDataType.ClipboardData:
|
|
106
|
-
channelData = ChannelData.clipboard(data);
|
|
107
|
-
break;
|
|
108
|
-
case ChannelDataType.ActionInput:
|
|
109
|
-
channelData = ChannelData.input(data, this.config.myId);
|
|
110
|
-
break;
|
|
111
|
-
case ChannelDataType.ActionChinese:
|
|
112
|
-
channelData = ChannelData.chinese(data);
|
|
113
|
-
break;
|
|
114
|
-
case ChannelDataType.ActionRequestCloudDeviceInfo:
|
|
115
|
-
channelData = ChannelData.requestCloudDeviceInfo();
|
|
116
|
-
break;
|
|
117
|
-
case ChannelDataType.ActionClarity:
|
|
118
|
-
channelData = ChannelData.clarity(data);
|
|
119
|
-
break;
|
|
120
|
-
case ChannelDataType.ActionWheel:
|
|
121
|
-
channelData = ChannelData.wheel(data);
|
|
122
|
-
break;
|
|
123
|
-
case ChannelDataType.ActionGesture:
|
|
124
|
-
channelData = ChannelData.gesture(data);
|
|
125
|
-
break
|
|
126
|
-
case ChannelDataType.ActionCommand:
|
|
127
|
-
channelData = ChannelData.action(data);
|
|
128
|
-
break
|
|
129
|
-
case ChannelDataType.ActionCommandEvent:
|
|
130
|
-
channelData = ChannelData.switchAudio(data)
|
|
131
|
-
break
|
|
132
|
-
case ChannelDataType.ActionTrack:
|
|
133
|
-
channelData = ChannelData.changeSender(data);
|
|
134
|
-
break
|
|
135
|
-
case ChannelDataType.RequestKeyFrame:
|
|
136
|
-
channelData = ChannelData.requestKeyFrame(data);
|
|
137
|
-
break
|
|
138
|
-
}
|
|
139
|
-
if(channelData){
|
|
140
|
-
const jsonString = JSON.stringify(channelData)
|
|
141
|
-
const buffer = new TextEncoder().encode(jsonString).buffer;
|
|
142
|
-
this.dataChannel?.send(buffer);
|
|
143
|
-
channelData = null
|
|
144
|
-
}
|
|
145
|
-
} catch (err) {
|
|
146
|
-
this.emit(EmitType.webrtcError, createWebRtcError(FailCode.DATACHANNEL_ERR, err))
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
public closeConnection() {
|
|
152
|
-
Logger.info('信息日志:', '关闭webrtc连接=======>')
|
|
153
|
-
if (this.statsTimer) {
|
|
154
|
-
clearInterval(this.statsTimer);
|
|
155
|
-
this.statsTimer = undefined;
|
|
156
|
-
}
|
|
157
|
-
this.stopPush()
|
|
158
|
-
this.stopLocal()
|
|
159
|
-
// if(this.decoder && this.decoder.state === "configured"){
|
|
160
|
-
// this.decoder.flush();
|
|
161
|
-
// }
|
|
162
|
-
if(this.peerConnection){
|
|
163
|
-
// 1. 停止所有发送轨道(释放摄像头/麦克风)
|
|
164
|
-
if(typeof this.peerConnection.getSenders === 'function' && this.peerConnection.getSenders){
|
|
165
|
-
this.peerConnection.getSenders().forEach(sender => {
|
|
166
|
-
if (sender.track) {
|
|
167
|
-
sender.track.stop?.();
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
// 2. 停止所有接收轨道
|
|
172
|
-
if(typeof this.peerConnection.getReceivers === 'function' && this.peerConnection.getReceivers){
|
|
173
|
-
this.peerConnection.getReceivers().forEach(receiver => {
|
|
174
|
-
if (receiver.track) {
|
|
175
|
-
receiver.track.stop?.();
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
// 3. 关闭所有 transceivers(彻底清除资源)
|
|
180
|
-
if (this.peerConnection.getTransceivers) {
|
|
181
|
-
this.peerConnection.getTransceivers().forEach(transceiver => {
|
|
182
|
-
transceiver.stop?.();
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
// 4. 关闭连接
|
|
186
|
-
this.removeAllListeners()
|
|
187
|
-
this.videoDataChannel?.close()
|
|
188
|
-
this.videoDataChannel = null
|
|
189
|
-
this.dataChannel?.close()
|
|
190
|
-
this.dataChannel = null
|
|
191
|
-
// 5. 清除事件监听器(如果你手动添加了)
|
|
192
|
-
this.peerConnection.onicecandidate = null;
|
|
193
|
-
this.peerConnection.ontrack = null;
|
|
194
|
-
this.peerConnection.ondatachannel = null;
|
|
195
|
-
this.peerConnection.onconnectionstatechange = null;
|
|
196
|
-
this.peerConnection.oniceconnectionstatechange = null;
|
|
197
|
-
this.peerConnection.onsignalingstatechange = null;
|
|
198
|
-
this.peerConnection.onnegotiationneeded = null;
|
|
199
|
-
this.peerConnection.onicegatheringstatechange = null;
|
|
200
|
-
this.peerConnection.close();
|
|
201
|
-
this.peerConnection = null;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async readyCapture(useBackCamera: boolean) {
|
|
207
|
-
this.resetPeerConnection();
|
|
208
|
-
this.stopPush()
|
|
209
|
-
Logger.info('信息日志:', '启用摄像头推流到云机=======>', useBackCamera)
|
|
210
|
-
try {
|
|
211
|
-
this.localStream = await navigator.mediaDevices.getUserMedia({
|
|
212
|
-
video: { width: { ideal: 1280 }, height: { ideal: 720 },
|
|
213
|
-
facingMode: useBackCamera ? { ideal: 'user' } : { ideal: 'user' }, frameRate: { ideal: 20, max: 30 }},
|
|
214
|
-
audio: true
|
|
215
|
-
});
|
|
216
|
-
const senders: RTCRtpSender[] = [];
|
|
217
|
-
const rotatedStream = await this.getRotatedStream(this.localStream, useBackCamera);
|
|
218
|
-
// const rotatedStream = this.localStream
|
|
219
|
-
this.rotatedStream = rotatedStream;
|
|
220
|
-
|
|
221
|
-
rotatedStream.getTracks().forEach(track => {
|
|
222
|
-
track.contentHint = 'motion';
|
|
223
|
-
const sender = this.peerConnection!.addTrack(track, rotatedStream);
|
|
224
|
-
senders.push(sender);
|
|
225
|
-
});
|
|
226
|
-
await createOffer(this.peerConnection!, (sdp: string) => {
|
|
227
|
-
this.emit(EmitType.sendOffer, sdp);
|
|
228
|
-
});
|
|
229
|
-
senders.forEach(sender => this.setVideoParams(sender));
|
|
230
|
-
|
|
231
|
-
} catch (err) {
|
|
232
|
-
if (err instanceof DOMException) {
|
|
233
|
-
switch (err.name) {
|
|
234
|
-
case 'NotAllowedError':
|
|
235
|
-
throw new Error("用户拒绝了摄像头或麦克风权限");
|
|
236
|
-
case 'NotFoundError':
|
|
237
|
-
throw new Error("未找到摄像头或麦克风设备");
|
|
238
|
-
case 'OverconstrainedError':
|
|
239
|
-
throw new Error("设备无法满足指定约束");
|
|
240
|
-
case 'NotReadableError':
|
|
241
|
-
throw new Error("设备忙或无法访问");
|
|
242
|
-
default:
|
|
243
|
-
throw new Error(`其他错误: ${err.message}`);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
throw new Error(`mediaDevices 异常: ${err}`);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* ✅ 切换前后摄像头(无缝)
|
|
252
|
-
*/
|
|
253
|
-
async switchCamera(useBackCamera: boolean) {
|
|
254
|
-
try {
|
|
255
|
-
Logger.info('信息日志:', `切换摄像头 => ${useBackCamera ? '后置' : '前置'}`);
|
|
256
|
-
this.isPushingStream = true;
|
|
257
|
-
// 1️⃣ 获取新的摄像头流
|
|
258
|
-
this.localStream = await navigator.mediaDevices.getUserMedia({
|
|
259
|
-
video: {
|
|
260
|
-
width: { ideal: 1280 },
|
|
261
|
-
height: { ideal: 720 },
|
|
262
|
-
facingMode: useBackCamera ? { ideal: 'environment' } : { ideal: 'user' },
|
|
263
|
-
frameRate: { ideal: 20, max: 30 },
|
|
264
|
-
},
|
|
265
|
-
audio: false // 音频不需要重新采集
|
|
266
|
-
});
|
|
267
|
-
const rotatedStream = await this.getRotatedStream(this.localStream, useBackCamera);
|
|
268
|
-
// 3️⃣ 释放旧的摄像头资源
|
|
269
|
-
// 4️⃣ 更新本地流引用
|
|
270
|
-
this.rotatedStream = rotatedStream;
|
|
271
|
-
const newTrack = this.rotatedStream.getVideoTracks()[0];
|
|
272
|
-
|
|
273
|
-
// 2️⃣ 找到原 video sender
|
|
274
|
-
const sender = this.peerConnection?.getSenders()
|
|
275
|
-
.find(s => s.track && s.track.kind === 'video');
|
|
276
|
-
|
|
277
|
-
if (sender) {
|
|
278
|
-
// ✅ 无缝替换视频 track
|
|
279
|
-
await sender.replaceTrack(newTrack);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Logger.info('信息日志:', `摄像头切换完成 => ${useBack ? '后置' : '前置'}`);
|
|
283
|
-
} catch (err) {
|
|
284
|
-
this.isPushingStream = false;
|
|
285
|
-
if (err instanceof DOMException) {
|
|
286
|
-
switch (err.name) {
|
|
287
|
-
case 'NotAllowedError':
|
|
288
|
-
throw new Error("用户拒绝了摄像头或麦克风权限");
|
|
289
|
-
case 'NotFoundError':
|
|
290
|
-
throw new Error("未找到摄像头或麦克风设备");
|
|
291
|
-
case 'OverconstrainedError':
|
|
292
|
-
throw new Error("设备无法满足指定约束");
|
|
293
|
-
case 'NotReadableError':
|
|
294
|
-
throw new Error("设备忙或无法访问");
|
|
295
|
-
default:
|
|
296
|
-
throw new Error(`其他错误: ${err.message}`);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
throw new Error(`mediaDevices 异常: ${err}`);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
private async getRotatedStream(localStream: MediaStream, useBackCamera: boolean): Promise<MediaStream> {
|
|
304
|
-
// ✅ 创建 canvas,不插入 DOM(或插入但隐藏)
|
|
305
|
-
const canvas = document.createElement('canvas');
|
|
306
|
-
canvas.width = 1280;
|
|
307
|
-
canvas.height = 720;
|
|
308
|
-
|
|
309
|
-
// ✅ 可选安全隐藏法(Safari 不会暂停绘制)
|
|
310
|
-
canvas.style.position = 'absolute';
|
|
311
|
-
canvas.style.top = '-9999px';
|
|
312
|
-
canvas.style.left = '-9999px';
|
|
313
|
-
canvas.style.width = '1px';
|
|
314
|
-
canvas.style.height = '1px';
|
|
315
|
-
canvas.style.opacity = '0';
|
|
316
|
-
document.body.appendChild(canvas);
|
|
317
|
-
|
|
318
|
-
const ctx = canvas.getContext('2d')!;
|
|
319
|
-
|
|
320
|
-
const video = document.createElement('video');
|
|
321
|
-
video.srcObject = localStream;
|
|
322
|
-
video.muted = true; // 避免回声
|
|
323
|
-
video.playsInline = true;
|
|
324
|
-
video.autoplay = true;
|
|
325
|
-
video.setAttribute('playsinline', 'true');
|
|
326
|
-
video.setAttribute('webkit-playsinline', 'true');
|
|
327
|
-
await video.play();
|
|
328
|
-
|
|
329
|
-
const width = video.videoWidth;
|
|
330
|
-
const height = video.videoHeight;
|
|
331
|
-
|
|
332
|
-
// 旋转逻辑
|
|
333
|
-
const drawFrame = () => {
|
|
334
|
-
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
335
|
-
ctx.save();
|
|
336
|
-
ctx.translate(canvas.width / 2, canvas.height / 2);
|
|
337
|
-
const angle = useBackCamera ? -Math.PI / 2 : Math.PI / 2;
|
|
338
|
-
ctx.rotate(angle);
|
|
339
|
-
ctx.drawImage(video, -width / 2, -height / 2, width, height);
|
|
340
|
-
ctx.restore();
|
|
341
|
-
};
|
|
342
|
-
|
|
343
|
-
const fps = 25;
|
|
344
|
-
const frameInterval = 1000 / fps;
|
|
345
|
-
let lastTime = 0;
|
|
346
|
-
let rafId: number;
|
|
347
|
-
|
|
348
|
-
const drawLoop = (time: number) => {
|
|
349
|
-
if (time - lastTime >= frameInterval) {
|
|
350
|
-
drawFrame();
|
|
351
|
-
lastTime = time;
|
|
352
|
-
}
|
|
353
|
-
rafId = requestAnimationFrame(drawLoop);
|
|
354
|
-
};
|
|
355
|
-
rafId = requestAnimationFrame(drawLoop);
|
|
356
|
-
|
|
357
|
-
const stream = canvas.captureStream(fps);
|
|
358
|
-
|
|
359
|
-
// 清理逻辑
|
|
360
|
-
stream.getTracks().forEach(track => {
|
|
361
|
-
const stopLoop = () => {
|
|
362
|
-
cancelAnimationFrame(rafId);
|
|
363
|
-
// 可选:移除 canvas
|
|
364
|
-
canvas.remove();
|
|
365
|
-
};
|
|
366
|
-
track.addEventListener('ended', stopLoop);
|
|
367
|
-
track.addEventListener('stop', stopLoop);
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
return stream;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
// private async setVideoParams(sender: RTCRtpSender) {
|
|
375
|
-
// Logger.info('信息日志:', '设置推流视频参数=======>')
|
|
376
|
-
// const params = sender.getParameters();
|
|
377
|
-
// params.degradationPreference = 'balanced';
|
|
378
|
-
// const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);
|
|
379
|
-
// params.encodings.forEach(encoding => {
|
|
380
|
-
// encoding.maxBitrate = isMobile ? 800000 : 1000000;
|
|
381
|
-
// encoding.priority = 'high';
|
|
382
|
-
// encoding.scaleResolutionDownBy = 1.0;
|
|
383
|
-
// });
|
|
384
|
-
// await sender.setParameters(params);
|
|
385
|
-
// }
|
|
386
|
-
|
|
387
|
-
private async setVideoParams(sender: RTCRtpSender) {
|
|
388
|
-
const params = sender.getParameters();
|
|
389
|
-
// ✅ 根据设备动态设定目标参数
|
|
390
|
-
const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);
|
|
391
|
-
const targetBitrate = isMobile ? 600_000 : 1_800_000; // 移动端 0.6Mbps,PC 1.8Mbps
|
|
392
|
-
const targetFramerate = 30; // 移动端 24fps,PC 30fps
|
|
393
|
-
|
|
394
|
-
// ✅ degradationPreference 告诉浏览器“掉帧还是降分辨率”
|
|
395
|
-
// maintain-framerate:优先维持帧率(视频通话常用)
|
|
396
|
-
// maintain-resolution:优先保持清晰度(静态画面)
|
|
397
|
-
// balanced:自动平衡(推荐)
|
|
398
|
-
params.degradationPreference = 'maintain-resolution';
|
|
399
|
-
|
|
400
|
-
// ✅ 如果 encodings 为空则初始化
|
|
401
|
-
if (!params.encodings || params.encodings.length === 0) {
|
|
402
|
-
params.encodings = [{}];
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
params.encodings.forEach(encoding => {
|
|
406
|
-
encoding.maxBitrate = targetBitrate;
|
|
407
|
-
encoding.maxFramerate = targetFramerate;
|
|
408
|
-
encoding.scaleResolutionDownBy = 1.0;
|
|
409
|
-
encoding.priority = 'high';
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
try {
|
|
413
|
-
await sender.setParameters(params);
|
|
414
|
-
Logger.info('信息日志:', `设置推流视频参数成功:${targetBitrate / 1000}kbps / ${targetFramerate}fps`);
|
|
415
|
-
} catch (e) {
|
|
416
|
-
Logger.error('设置推流参数失败:', e);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
public stopPush() {
|
|
421
|
-
if (!this.isPushingStream) return;
|
|
422
|
-
this.isPushingStream = false;
|
|
423
|
-
Logger.info('信息日志:', '停止推流到云机=======>')
|
|
424
|
-
// 1. 停止本地原始流
|
|
425
|
-
this.localStream?.getTracks().forEach(track => track.stop());
|
|
426
|
-
this.localStream = null;
|
|
427
|
-
|
|
428
|
-
// 2. 停止旋转后的流
|
|
429
|
-
this.rotatedStream?.getTracks().forEach(track => track.stop());
|
|
430
|
-
this.rotatedStream = null;
|
|
431
|
-
|
|
432
|
-
// 3. 移除 PeerConnection 中的 sender
|
|
433
|
-
this.peerConnection?.getSenders().forEach(sender => {
|
|
434
|
-
try {
|
|
435
|
-
this.peerConnection?.removeTrack(sender);
|
|
436
|
-
} catch (e) {
|
|
437
|
-
console.warn("removeTrack error:", e);
|
|
438
|
-
}
|
|
439
|
-
});
|
|
440
|
-
// createOffer(this.peerConnection!, (sdp: string) => {
|
|
441
|
-
// this.emit(EmitType.sendOffer, sdp)
|
|
442
|
-
// }, err => this.emit(EmitType.webrtcError, err));
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
private resetPeerConnection() {
|
|
446
|
-
if (!this.peerConnection) {
|
|
447
|
-
this.peerConnection = createPeerConnection(this.config);
|
|
448
|
-
configPeerConnection(this.peerConnection,(candidate: string) => {
|
|
449
|
-
this.emit(EmitType.sendICEMessage, candidate)
|
|
450
|
-
}, (track: MediaStreamTrack) => {
|
|
451
|
-
this.emit(EmitType.streamTrack, track)
|
|
452
|
-
}, (state: RTCIceConnectionState) => {
|
|
453
|
-
this.emit(EmitType.iceConnectionState, state)
|
|
454
|
-
if(state === "connected"){
|
|
455
|
-
this.checkStats()
|
|
456
|
-
}
|
|
457
|
-
}, err => this.emit(EmitType.webrtcError, err));
|
|
458
|
-
this.configDataChannel();
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
private configDataChannel() {
|
|
463
|
-
this.peerConnection!.ondatachannel = event => {
|
|
464
|
-
const channel = event.channel;
|
|
465
|
-
|
|
466
|
-
// 根据 label 区分不同通道
|
|
467
|
-
switch (channel.label) {
|
|
468
|
-
case `dataChannel${this.config.myId}`:
|
|
469
|
-
this.dataChannel = channel;
|
|
470
|
-
this.dataChannel.onmessage = ev => this.handleDataChannelMessage(ev);
|
|
471
|
-
this.dataChannel.onerror = err => this.emit(EmitType.webrtcError, createWebRtcError(FailCode.DATACHANNEL_ERR, err));
|
|
472
|
-
this.sendChannelData(ChannelDataType.ActionRequestCloudDeviceInfo, '');
|
|
473
|
-
break;
|
|
474
|
-
|
|
475
|
-
case `video${this.config.myId}`:
|
|
476
|
-
this.videoDataChannel = channel;
|
|
477
|
-
this.videoDataChannel.onmessage = ev => this.handleVideoChannelMessage(ev);
|
|
478
|
-
this.videoDataChannel.onerror = err => this.emit(EmitType.webrtcError, createWebRtcError(FailCode.DATACHANNEL_ERR, err));
|
|
479
|
-
break;
|
|
480
|
-
|
|
481
|
-
default:
|
|
482
|
-
console.warn('Unknown data channel:', channel.label);
|
|
483
|
-
break;
|
|
484
|
-
}
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
private async handleVideoChannelMessage(event: MessageEvent){
|
|
489
|
-
const databuffer = event.data;
|
|
490
|
-
if (databuffer instanceof ArrayBuffer || databuffer instanceof Blob) {
|
|
491
|
-
// -----------------------
|
|
492
|
-
// 二进制数据处理
|
|
493
|
-
// -----------------------
|
|
494
|
-
// if(this.isFirst){
|
|
495
|
-
// this.sendChannelData(ChannelDataType.RequestKeyFrame, 'requestKeyFrame')
|
|
496
|
-
// this.isFirst = false
|
|
497
|
-
// }
|
|
498
|
-
const arrayBuffer = databuffer instanceof Blob
|
|
499
|
-
? await databuffer.arrayBuffer()
|
|
500
|
-
: databuffer;
|
|
501
|
-
|
|
502
|
-
this.emit(EmitType.arrayBuffer, arrayBuffer)
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
private async handleDataChannelMessage(event: MessageEvent) {
|
|
507
|
-
|
|
508
|
-
const data = JSON.parse(event.data);
|
|
509
|
-
if (data.type === ChannelDataType.ActionCommandEvent) {
|
|
510
|
-
const { action, value, cameraId } = JSON.parse(data.data);
|
|
511
|
-
if (action === 'ACTION_CONTROL_VIDEO') {
|
|
512
|
-
if(value === 'ENABLE'){
|
|
513
|
-
if(this.isPushingStream){
|
|
514
|
-
this.switchCamera(Number(cameraId) === 0)
|
|
515
|
-
}else {
|
|
516
|
-
this.startPush(Number(cameraId) === 0)
|
|
517
|
-
}
|
|
518
|
-
}else{
|
|
519
|
-
this.stopPush()
|
|
520
|
-
this.stopLocal()
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
} else if (data.type === ChannelDataType.ActionUpdateCloudStatus) {
|
|
524
|
-
const { rotation, screenWidth, screenHeight, gestureMode, level, isClarity } = JSON.parse(data.data);
|
|
525
|
-
const direction = [StreamRotation.ROTATION_0, StreamRotation.ROTATION_180].includes(rotation)
|
|
526
|
-
? ContainerDirection.Vertical
|
|
527
|
-
: ContainerDirection.Horizontal;
|
|
528
|
-
const cloudStatus:CloudStatusPayload = {
|
|
529
|
-
direction, screenWidth, screenHeight, gestureMode, clarityLevel: level, isClarity
|
|
530
|
-
}
|
|
531
|
-
this.emit(EmitType.cloudStatusChanged, cloudStatus);
|
|
532
|
-
}else if(data.type === ChannelDataType.CloudClipData){
|
|
533
|
-
this.emit(EmitType.cloudClipData, data.data)
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
private checkStats() {
|
|
538
|
-
this.statsTimer = setInterval(() => this.processStatsOptimized(), 1000);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
private async processStatsOptimized() {
|
|
542
|
-
const pc = this.peerConnection;
|
|
543
|
-
if (!pc) return;
|
|
544
|
-
|
|
545
|
-
// ---- 浏览器检测(Safari 兼容处理)----
|
|
546
|
-
const ua = navigator.userAgent;
|
|
547
|
-
const isSafari = /Safari/.test(ua) && !/Chrome/.test(ua);
|
|
548
|
-
const isMobile = /iPhone|iPad|Android/i.test(ua);
|
|
549
|
-
|
|
550
|
-
// Safari 上 getStats 性能较差,延长采样周期
|
|
551
|
-
const minInterval = isSafari || isMobile ? 3000 : 1000;
|
|
552
|
-
const now = Date.now();
|
|
553
|
-
if (this.lastReportTime && now - this.lastReportTime < minInterval) return; // 自动限频
|
|
554
|
-
|
|
555
|
-
try {
|
|
556
|
-
const statsReport = await pc.getStats(null);
|
|
557
|
-
|
|
558
|
-
// ---- 初始化变量 ----
|
|
559
|
-
let videoFps = 0;
|
|
560
|
-
let totalDecodeTime = 0;
|
|
561
|
-
let roundTripTime = 0;
|
|
562
|
-
let packetsLost = 0;
|
|
563
|
-
let packetsReceived = 0;
|
|
564
|
-
let bytesReceived = 0;
|
|
565
|
-
let framesDecoded = 0;
|
|
566
|
-
let framesReceived = 0;
|
|
567
|
-
let pliCount = 0;
|
|
568
|
-
let connectionType = "";
|
|
569
|
-
|
|
570
|
-
// ---- 遍历关键项(仅 inbound-rtp / candidate-pair)----
|
|
571
|
-
for (const report of statsReport.values()) {
|
|
572
|
-
if (report.type === "inbound-rtp" && report.kind === "video") {
|
|
573
|
-
bytesReceived += report.bytesReceived || 0;
|
|
574
|
-
packetsLost += report.packetsLost || 0;
|
|
575
|
-
packetsReceived += report.packetsReceived || 0;
|
|
576
|
-
|
|
577
|
-
// Safari 下 framesPerSecond 常缺失,用差值代替
|
|
578
|
-
videoFps = report.framesPerSecond || 0;
|
|
579
|
-
totalDecodeTime = report.totalDecodeTime || 0;
|
|
580
|
-
framesDecoded = report.framesDecoded || 0;
|
|
581
|
-
framesReceived = report.framesReceived || 0;
|
|
582
|
-
pliCount = (report.pliCount || 0) + (report.firCount || 0);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
if (report.type === "candidate-pair" && report.state === "succeeded") {
|
|
586
|
-
roundTripTime = typeof report.currentRoundTripTime !== "undefined"
|
|
587
|
-
? report.currentRoundTripTime
|
|
588
|
-
: report.responsesReceived > 0
|
|
589
|
-
? report.totalRoundTripTime / report.responsesReceived
|
|
590
|
-
: 0;
|
|
591
|
-
|
|
592
|
-
const local = statsReport.get(report.localCandidateId);
|
|
593
|
-
const remote = statsReport.get(report.remoteCandidateId);
|
|
594
|
-
if (local && remote) {
|
|
595
|
-
connectionType = this.getConnectionType(local, remote);
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// ---- RTT 单位转换 ----
|
|
601
|
-
if (roundTripTime > 1000) {
|
|
602
|
-
roundTripTime = Math.floor(roundTripTime / 1000);
|
|
603
|
-
} else if (roundTripTime < 1) {
|
|
604
|
-
roundTripTime = Math.floor(roundTripTime * 1000);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// ---- 带宽计算 ----
|
|
608
|
-
const deltaTime = now - (this.lastReportTime || now);
|
|
609
|
-
const bytesDiff = bytesReceived - (this.lastBytesReceived || 0);
|
|
610
|
-
const bytesPerSecond = deltaTime > 0 ? (bytesDiff * 1000) / deltaTime : 0;
|
|
611
|
-
const kilobytesPerSecond = bytesPerSecond / 1024;
|
|
612
|
-
const megabytesPerSecond = kilobytesPerSecond / 1024;
|
|
613
|
-
|
|
614
|
-
this.lastBytesReceived = bytesReceived;
|
|
615
|
-
this.lastReportTime = now;
|
|
616
|
-
|
|
617
|
-
// ---- 丢包率 ----
|
|
618
|
-
const lostPackets = packetsLost - (this.lastPacketsLost || 0);
|
|
619
|
-
const receivedPackets = packetsReceived - (this.lastPacketsReceived || 0);
|
|
620
|
-
let lossRate = 0;
|
|
621
|
-
if (lostPackets > 0 && receivedPackets > 0) {
|
|
622
|
-
lossRate = lostPackets / (lostPackets + receivedPackets);
|
|
623
|
-
this.maxLostRate = Math.max(this.maxLostRate || 0, lossRate);
|
|
624
|
-
this.lostPacketCount = (this.lostPacketCount || 0) + 1;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
this.lastPacketsLost = packetsLost;
|
|
628
|
-
this.lastPacketsReceived = packetsReceived;
|
|
629
|
-
|
|
630
|
-
// ---- FPS 计算 ----
|
|
631
|
-
const fps = videoFps || (framesDecoded - (this.lastSecondDecodedCount || 0));
|
|
632
|
-
this.lastSecondDecodedCount = framesDecoded;
|
|
633
|
-
|
|
634
|
-
// ---- 码率与解码耗时 ----
|
|
635
|
-
const bitrate = kilobytesPerSecond < 1024
|
|
636
|
-
? `${Math.floor(kilobytesPerSecond)} KB/s`
|
|
637
|
-
: `${Math.floor(megabytesPerSecond)} MB/s`;
|
|
638
|
-
|
|
639
|
-
const averageDecodeTime = framesDecoded > 0
|
|
640
|
-
? Math.floor((10000 * totalDecodeTime) / framesDecoded)
|
|
641
|
-
: 0;
|
|
642
|
-
|
|
643
|
-
// ---- 汇总结果 ----
|
|
644
|
-
const screenInfo: ScreenStats = {
|
|
645
|
-
connectionType,
|
|
646
|
-
framesPerSecond: fps,
|
|
647
|
-
currentRoundTripTime: roundTripTime,
|
|
648
|
-
lostRate: Math.floor(lossRate * 100),
|
|
649
|
-
bitrate,
|
|
650
|
-
pliCount,
|
|
651
|
-
averageDecodeTime,
|
|
652
|
-
framesDecoded,
|
|
653
|
-
framesReceived,
|
|
654
|
-
};
|
|
655
|
-
|
|
656
|
-
this.emit(EmitType.statisticInfo, screenInfo);
|
|
657
|
-
} catch (error) {
|
|
658
|
-
this.emit(EmitType.webrtcError, createWebRtcError(FailCode.STREAM_STATE, error));
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
private getConnectionType(local: any, remote: any): string {
|
|
664
|
-
if (local.candidateType === 'host' && remote.candidateType === 'host') return '直连';
|
|
665
|
-
if (local.candidateType === 'relay' || remote.candidateType === 'relay') return '中继';
|
|
666
|
-
return 'NAT';
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
/** 获取或创建 video 元素(懒初始化) */
|
|
670
|
-
private getFileVideo(): HTMLVideoElement {
|
|
671
|
-
if (!this.fileVideo) {
|
|
672
|
-
this.fileVideo = document.createElement('video');
|
|
673
|
-
this.fileVideo.muted = true; // 避免回声
|
|
674
|
-
this.fileVideo.playsInline = true;
|
|
675
|
-
}
|
|
676
|
-
return this.fileVideo;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
/** 获取或创建 canvas 元素(懒初始化) */
|
|
680
|
-
private getCanvas(): HTMLCanvasElement {
|
|
681
|
-
if (!this.canvas) {
|
|
682
|
-
this.canvas = document.createElement('canvas');
|
|
683
|
-
this.canvas.width = 640;
|
|
684
|
-
this.canvas.height = 480;
|
|
685
|
-
}
|
|
686
|
-
return this.canvas;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
public async loadMedia(file: File) {
|
|
690
|
-
const video = this.getFileVideo();
|
|
691
|
-
const img = this.getFileImage();
|
|
692
|
-
const type = file.type.toLowerCase();
|
|
693
|
-
const ext = file.name.split('.').pop()?.toLowerCase();
|
|
694
|
-
|
|
695
|
-
const videoExts = ['mp4', 'webm', 'ogg', 'mov', 'mkv'];
|
|
696
|
-
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
|
|
697
|
-
|
|
698
|
-
// 1. MIME + 扩展名初步判断
|
|
699
|
-
let kind: 'video' | 'image' | 'unknown' = 'unknown';
|
|
700
|
-
if (type.startsWith('video/') || (ext && videoExts.includes(ext))) {
|
|
701
|
-
kind = 'video';
|
|
702
|
-
} else if (type.startsWith('image/') || (ext && imageExts.includes(ext))) {
|
|
703
|
-
kind = 'image';
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// 2. 如果 MIME/扩展名都不可靠,再用魔数嗅探
|
|
707
|
-
if (kind === 'unknown') {
|
|
708
|
-
kind = await FileTypeUtils.detectFileType(file);
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
if (kind === 'video') {
|
|
712
|
-
return new Promise<void>((resolve, reject) => {
|
|
713
|
-
video.src = URL.createObjectURL(file);
|
|
714
|
-
video.onloadedmetadata = () => resolve();
|
|
715
|
-
video.onerror = () => reject(new Error(`视频文件加载失败: ${file.name}`));
|
|
716
|
-
video.play().catch(err => reject(new Error(`视频播放失败: ${err}`)));
|
|
717
|
-
this.currentMedia = video;
|
|
718
|
-
});
|
|
719
|
-
} else if (kind === 'image') {
|
|
720
|
-
return new Promise<void>((resolve, reject) => {
|
|
721
|
-
img.src = URL.createObjectURL(file);
|
|
722
|
-
img.onload = () => resolve();
|
|
723
|
-
img.onerror = () => reject(new Error(`图片文件加载失败: ${file.name}`));
|
|
724
|
-
this.currentMedia = img;
|
|
725
|
-
});
|
|
726
|
-
} else {
|
|
727
|
-
throw new Error(`不支持的文件类型: ${type || ext}`);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
public startPushLocal = async (file: File) => {
|
|
733
|
-
if (this.isPushingLocalStream || !file) return;
|
|
734
|
-
try {
|
|
735
|
-
await this.loadMedia(file); // 视频或图片
|
|
736
|
-
this.startCanvasStream(); // 转成 640x480 canvas 流
|
|
737
|
-
const senders: RTCRtpSender[] = [];
|
|
738
|
-
this.canvasStream?.getTracks().forEach(track => {
|
|
739
|
-
track.contentHint = 'detail';
|
|
740
|
-
const sender = this.peerConnection!.addTrack(track, this.canvasStream!);
|
|
741
|
-
senders.push(sender);
|
|
742
|
-
});
|
|
743
|
-
senders.forEach(sender => this.setVideoParams(sender));
|
|
744
|
-
|
|
745
|
-
await createOffer(this.peerConnection!, (sdp: string) => {
|
|
746
|
-
this.emit(EmitType.sendOffer, sdp);
|
|
747
|
-
});
|
|
748
|
-
this.isPushingLocalStream = true;
|
|
749
|
-
} catch (err) {
|
|
750
|
-
this.isPushingLocalStream = false;
|
|
751
|
-
let errorMessage: string;
|
|
752
|
-
if (err instanceof Error) {
|
|
753
|
-
errorMessage = err.message; // ✅ 只取 message
|
|
754
|
-
} else {
|
|
755
|
-
errorMessage = String(err); // 兜底,防止 err 不是 Error 类型
|
|
756
|
-
}
|
|
757
|
-
this.emit(
|
|
758
|
-
EmitType.cameraError,
|
|
759
|
-
createCameraError(CameraFailCode.LOCAL_STREAM_FAIL, errorMessage)
|
|
760
|
-
);
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
public startCanvasStream(fps = 30): MediaStream {
|
|
765
|
-
Logger.info('信息日志:', '初始化,使用本地文件推流到云机=======>')
|
|
766
|
-
const canvas = this.getCanvas();
|
|
767
|
-
const ctx = canvas.getContext('2d')!;
|
|
768
|
-
const media = this.currentMedia;
|
|
769
|
-
if (!media) throw new Error('请先加载媒体文件');
|
|
770
|
-
|
|
771
|
-
let lastTime = 0;
|
|
772
|
-
const frameInterval = 1000 / fps;
|
|
773
|
-
let rafId: number = 1;
|
|
774
|
-
|
|
775
|
-
const drawFrame = (time: number) => {
|
|
776
|
-
if (time - lastTime >= frameInterval) {
|
|
777
|
-
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
778
|
-
ctx.save();
|
|
779
|
-
|
|
780
|
-
// 旋转 + 水平翻转
|
|
781
|
-
ctx.translate(canvas.width / 2, canvas.height / 2);
|
|
782
|
-
ctx.rotate(Math.PI / 2);
|
|
783
|
-
ctx.scale(-1, 1);
|
|
784
|
-
|
|
785
|
-
// 获取媒体宽高
|
|
786
|
-
let mediaWidth: number;
|
|
787
|
-
let mediaHeight: number;
|
|
788
|
-
if (media instanceof HTMLVideoElement) {
|
|
789
|
-
mediaWidth = media.videoWidth;
|
|
790
|
-
mediaHeight = media.videoHeight;
|
|
791
|
-
} else if (media instanceof HTMLImageElement) {
|
|
792
|
-
mediaWidth = media.width;
|
|
793
|
-
mediaHeight = media.height;
|
|
794
|
-
} else {
|
|
795
|
-
throw new Error('不支持的媒体类型');
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// 等比例缩放
|
|
799
|
-
const scale = Math.min(canvas.height / mediaWidth, canvas.width / mediaHeight);
|
|
800
|
-
const w = mediaWidth * scale;
|
|
801
|
-
const h = mediaHeight * scale;
|
|
802
|
-
|
|
803
|
-
ctx.drawImage(media, -w / 2, -h / 2, w, h);
|
|
804
|
-
ctx.restore();
|
|
805
|
-
|
|
806
|
-
lastTime = time;
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
// 视频模式继续刷新,图片模式只刷新一次
|
|
810
|
-
rafId = requestAnimationFrame(drawFrame);
|
|
811
|
-
};
|
|
812
|
-
|
|
813
|
-
// 首次绘制
|
|
814
|
-
drawFrame(0);
|
|
815
|
-
this.rafId = requestAnimationFrame(drawFrame);
|
|
816
|
-
|
|
817
|
-
// 生成 MediaStream
|
|
818
|
-
this.canvasStream = canvas.captureStream(fps);
|
|
819
|
-
|
|
820
|
-
// 停止流时清理 RAF
|
|
821
|
-
this.canvasStream.getTracks().forEach(track => {
|
|
822
|
-
const stopLoop = () => cancelAnimationFrame(rafId);
|
|
823
|
-
track.addEventListener('ended', stopLoop);
|
|
824
|
-
track.addEventListener('stop', stopLoop);
|
|
825
|
-
});
|
|
826
|
-
|
|
827
|
-
return this.canvasStream;
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
public getFileImage(): HTMLImageElement {
|
|
832
|
-
if (!this.fileImage) {
|
|
833
|
-
const img = document.createElement('img');
|
|
834
|
-
img.style.display = 'none'; // 隐藏
|
|
835
|
-
document.body.appendChild(img);
|
|
836
|
-
this.fileImage = img;
|
|
837
|
-
}
|
|
838
|
-
return this.fileImage;
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
/** 停止推流并释放资源 */
|
|
843
|
-
public stopLocal() {
|
|
844
|
-
if (!this.isPushingLocalStream) return;
|
|
845
|
-
this.isPushingLocalStream = false;
|
|
846
|
-
cancelAnimationFrame(this.rafId);
|
|
847
|
-
this.canvasStream?.getTracks().forEach(track => track.stop());
|
|
848
|
-
this.canvasStream = null;
|
|
849
|
-
// 3. 移除 PeerConnection 中的 sender
|
|
850
|
-
this.peerConnection?.getSenders().forEach(sender => {
|
|
851
|
-
try {
|
|
852
|
-
this.peerConnection?.removeTrack(sender);
|
|
853
|
-
} catch (e) {
|
|
854
|
-
Logger.error('错误日志:', `移除音视频轨道失败=====>`, e);
|
|
855
|
-
}
|
|
856
|
-
});
|
|
857
|
-
if (this.fileVideo) {
|
|
858
|
-
this.fileVideo.pause();
|
|
859
|
-
this.fileVideo.src = '';
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
}
|