yjz-web-sdk 1.0.10 → 1.0.11-beta.2

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.
Files changed (62) hide show
  1. package/lib/components/RemotePlayer/index.vue.d.ts +1 -73
  2. package/lib/composables/useCursorStyle.d.ts +1 -1
  3. package/lib/composables/useKeyboardControl.d.ts +5 -1
  4. package/lib/composables/useMouseTouchControl.d.ts +5 -4
  5. package/lib/composables/useRemoteVideo.d.ts +8 -25
  6. package/lib/composables/useResizeObserver.d.ts +1 -1
  7. package/lib/core/data/WebRtcError.d.ts +1 -2
  8. package/lib/core/data/WebrtcDataType.d.ts +1 -11
  9. package/lib/core/groupctrl/SdkController.d.ts +2 -2
  10. package/lib/core/rtc/WebRTCClient.d.ts +2 -5
  11. package/lib/core/rtc/WebRTCConfig.d.ts +1 -1
  12. package/lib/core/rtc/WebRtcNegotiate.d.ts +2 -2
  13. package/lib/core/signal/SignalingClient.d.ts +1 -1
  14. package/lib/core/util/KeyCodeUtil.d.ts +6 -0
  15. package/lib/core/util/TurnTestUtil.d.ts +2 -2
  16. package/lib/yjz-web-sdk.js +728 -1307
  17. package/package.json +5 -16
  18. package/lib/components/RemotePlayer/type.d.ts +0 -9
  19. package/lib/core/util/MapCache.d.ts +0 -20
  20. package/lib/render/Canvas2DRenderer.d.ts +0 -10
  21. package/lib/render/WebGLRenderer.d.ts +0 -16
  22. package/lib/render/WebGPURenderer.d.ts +0 -18
  23. package/lib/types/index.d.ts +0 -13
  24. package/lib/util/WasmUtil.d.ts +0 -17
  25. package/lib/worker/worker.d.ts +0 -1
  26. package/src/assets/icon/circle.svg +0 -1
  27. package/src/assets/icon/triangle.svg +0 -1
  28. package/src/assets/wasm/h264-atomic.wasm +0 -0
  29. package/src/assets/wasm/h264-simd.wasm +0 -0
  30. package/src/components/RemotePlayer/index.vue +0 -170
  31. package/src/components/RemotePlayer/type.ts +0 -11
  32. package/src/composables/useCursorStyle.ts +0 -15
  33. package/src/composables/useKeyboardControl.ts +0 -32
  34. package/src/composables/useMouseTouchControl.ts +0 -158
  35. package/src/composables/useRemoteVideo.ts +0 -248
  36. package/src/composables/useResizeObserver.ts +0 -27
  37. package/src/core/WebRTCSdk.ts +0 -561
  38. package/src/core/data/MessageType.ts +0 -70
  39. package/src/core/data/TurnType.ts +0 -25
  40. package/src/core/data/WebRtcError.ts +0 -93
  41. package/src/core/data/WebrtcDataType.ts +0 -354
  42. package/src/core/groupctrl/GroupCtrlSocketManager.ts +0 -94
  43. package/src/core/groupctrl/SdkController.ts +0 -96
  44. package/src/core/rtc/WebRTCClient.ts +0 -862
  45. package/src/core/rtc/WebRTCConfig.ts +0 -86
  46. package/src/core/rtc/WebRtcNegotiate.ts +0 -164
  47. package/src/core/signal/SignalingClient.ts +0 -221
  48. package/src/core/util/FileTypeUtils.ts +0 -75
  49. package/src/core/util/KeyCodeUtil.ts +0 -162
  50. package/src/core/util/Logger.ts +0 -83
  51. package/src/core/util/MapCache.ts +0 -135
  52. package/src/core/util/ScreenControlUtil.ts +0 -174
  53. package/src/core/util/TurnTestUtil.ts +0 -123
  54. package/src/env.d.ts +0 -30
  55. package/src/index.ts +0 -61
  56. package/src/render/Canvas2DRenderer.ts +0 -38
  57. package/src/render/WebGLRenderer.ts +0 -150
  58. package/src/render/WebGPURenderer.ts +0 -194
  59. package/src/types/index.ts +0 -15
  60. package/src/types/webgpu.d.ts +0 -1158
  61. package/src/util/WasmUtil.ts +0 -291
  62. 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
- }