nodeplayer-addon 0.3.1 → 0.3.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.
@@ -0,0 +1,436 @@
1
+ var VideoPlayer = (function () {
2
+ 'use strict';
3
+
4
+ /**
5
+ * VideoPlayer — MediaSource Extension based video player for Electron renderer.
6
+ *
7
+ * Works with the IPC bridge set up by NodePlayer.registerIpc() in the main process
8
+ * and the preload script that exposes window.electronAPI.
9
+ *
10
+ * Usage (ESM in React/Vue):
11
+ * import VideoPlayer from 'nodeplayer-addon/video-player'
12
+ * const player = new VideoPlayer(videoEl, 'my-id')
13
+ * player.on('event', (code, msg) => { ... })
14
+ * player.on('error', (err) => { ... })
15
+ * await player.start('rtsp://...')
16
+ *
17
+ * Usage (UMD in HTML):
18
+ * <script src="video-player.umd.js"></script>
19
+ * <script>
20
+ * const player = new VideoPlayer(videoEl, 'my-id')
21
+ * player.on('event', (code, msg) => { ... })
22
+ * player.on('error', (err) => { ... })
23
+ * player.start('rtsp://...')
24
+ * </script>
25
+ */
26
+ class VideoPlayer {
27
+ /**
28
+ * @param {HTMLVideoElement} video - The <video> element to render into
29
+ * @param {string} id - Unique player identifier (used for IPC channels)
30
+ * @param {object} [options]
31
+ * @param {object} [options.api] - IPC bridge (default: window.electronAPI)
32
+ * @param {number} [options.maxBufferDuration=15] - 直播缓冲上限(秒),超过则触发清理
33
+ * @param {number} [options.keepBehindDuration=5] - 清理时保留当前播放点之前的秒数
34
+ * @param {number} [options.targetAhead=0.5] - 追赶后的目标 ahead 值(秒)
35
+ * @param {number} [options.maxAhead=3] - ahead 超过此值时触发 seek 追赶
36
+ */
37
+ constructor(video, id, options = {}) {
38
+ this.id = id;
39
+ this.video = video;
40
+ this._api = options.api || (typeof window !== 'undefined' && window.electronAPI) || null;
41
+ this._listeners = {};
42
+
43
+ this.mediaSource = null;
44
+ this.sourceBuffer = null;
45
+ this.queue = [];
46
+ this.isStarted = false;
47
+ this.isReady = false;
48
+ this.isRecording = false;
49
+ this.videoCodecString = null;
50
+ this.audioCodecString = null;
51
+
52
+ this._videoWidth = 0;
53
+ this._videoHeight = 0;
54
+ this._canvas = null;
55
+ this._canvasCtx = null;
56
+
57
+ this._unsubEvent = null;
58
+ this._unsubInfo = null;
59
+ this._unsubData = null;
60
+ this._bufferStatsTimer = null;
61
+
62
+ // 直播场景的缓冲控制(主动清理已播放数据,避免无限增长)
63
+ // 触发清理的总缓冲阈值
64
+ this._maxBufferDuration = options.maxBufferDuration || 30;
65
+ // 清理时保留当前播放点之前的秒数(抗抖动 + 允许短暂回看)
66
+ this._keepBehindDuration = options.keepBehindDuration || 5;
67
+
68
+ // 直播延迟追赶(任何原因导致 ahead 累积过大时,seek 到接近 buffer 末端)
69
+ // 追赶后的目标 ahead 值
70
+ this._targetAhead = options.targetAhead != null ? options.targetAhead : 0.3;
71
+ // ahead 超过此阈值时触发追赶(截图/卡顿/IPC 慢等场景)
72
+ this._maxAhead = options.maxAhead != null ? options.maxAhead : 3;
73
+ }
74
+
75
+ /**
76
+ * 注册事件监听器
77
+ * @param {'event'|'error'} event - 事件名
78
+ * @param {function} fn - 'event': (code: number, msg: string) => void; 'error': (err: Error) => void
79
+ * @returns {this} 支持链式调用
80
+ */
81
+ on(event, fn) {
82
+ if (!this._listeners[event]) this._listeners[event] = new Set();
83
+ this._listeners[event].add(fn);
84
+ return this
85
+ }
86
+
87
+ /**
88
+ * 移除事件监听器
89
+ * @param {'event'|'error'} event - 事件名
90
+ * @param {function} fn - 要移除的监听函数
91
+ * @returns {this}
92
+ */
93
+ off(event, fn) {
94
+ if (this._listeners[event]) this._listeners[event].delete(fn);
95
+ return this
96
+ }
97
+
98
+ _emit(event, ...args) {
99
+ const set = this._listeners[event];
100
+ if (set) for (const fn of set) fn(...args);
101
+ }
102
+
103
+ /**
104
+ * 启动播放
105
+ * @param {string} url - RTSP/RTMP/KMP 地址
106
+ */
107
+ async start(url) {
108
+ if (this.isStarted) return
109
+
110
+ const createResult = await this._api.createPlayer(this.id);
111
+ if (!createResult.success) {
112
+ this._emit('error', new Error(createResult.error));
113
+ return
114
+ }
115
+
116
+ this._unsubEvent = this._api.onEvent(this.id, (data) => {
117
+ this._handleEvent(data.code, data.msg);
118
+ });
119
+
120
+ this._unsubInfo = this._api.onInfo(this.id, (info) => {
121
+ this.videoCodecString = info.video ? info.video.codecString : null;
122
+ this.audioCodecString = info.audio ? info.audio.codecString : null;
123
+ if (info.video && info.video.width && info.video.height) {
124
+ this._videoWidth = info.video.width;
125
+ this._videoHeight = info.video.height;
126
+ this._initCanvas();
127
+ }
128
+ this._initMediaSource();
129
+ });
130
+
131
+ this._unsubData = this._api.onData(this.id, (data) => {
132
+ this._handleData(data);
133
+ });
134
+
135
+ const startResult = await this._api.startPlayer(this.id, url);
136
+ if (!startResult.success) {
137
+ this._emit('error', new Error(startResult.error));
138
+ this._cleanupSubscriptions();
139
+ return
140
+ }
141
+
142
+ this.isStarted = true;
143
+ // this._startBufferStats()
144
+ }
145
+
146
+ /**
147
+ * 停止播放并释放资源
148
+ */
149
+ async stop() {
150
+ if (!this.isStarted) return
151
+ this.isStarted = false;
152
+ this.isReady = false;
153
+
154
+ // this._stopBufferStats()
155
+ this._cleanupSubscriptions();
156
+
157
+ try {
158
+ await this._api.stopPlayer(this.id);
159
+ await this._api.destroyPlayer(this.id);
160
+ } catch (e) { /* ignore */ }
161
+
162
+ this._destroyMediaSource();
163
+ this._canvas = null;
164
+ this._canvasCtx = null;
165
+ this._videoWidth = 0;
166
+ this._videoHeight = 0;
167
+ this.queue = [];
168
+ this.isRecording = false;
169
+ }
170
+
171
+ /**
172
+ * 开始录像
173
+ * @param {string} [outputPath] - 输出文件路径(可选,由主进程自动生成)
174
+ * @returns {Promise<{success: boolean, path?: string, error?: string}>}
175
+ */
176
+ async startRecord(outputPath) {
177
+ if (!this.isStarted) return { success: false, error: 'Player not started' }
178
+ try {
179
+ const result = await this._api.startRecord(this.id, outputPath);
180
+ if (result.success) this.isRecording = true;
181
+ return result
182
+ } catch (e) {
183
+ return { success: false, error: e.message }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * 停止录像
189
+ * @returns {Promise<{success: boolean, error?: string}>}
190
+ */
191
+ async stopRecord() {
192
+ if (!this.isStarted) return { success: false, error: 'Player not started' }
193
+ try {
194
+ const result = await this._api.stopRecord(this.id);
195
+ if (result.success) this.isRecording = false;
196
+ return result
197
+ } catch (e) {
198
+ return { success: false, error: e.message }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * 截取当前视频帧,返回 JPG 格式的 data URL
204
+ * @param {number} [quality=0.9] - JPG 质量 (0-1)
205
+ * @returns {string|null} data URL (image/jpeg),未就绪时返回 null
206
+ */
207
+ captureScreenshot(quality = 0.9) {
208
+ if (!this.isReady || !this._canvas || !this.video) return null
209
+ this._canvasCtx.drawImage(this.video, 0, 0, this._videoWidth, this._videoHeight);
210
+ return this._canvas.toDataURL('image/jpeg', quality)
211
+ }
212
+
213
+ /**
214
+ * 截取当前视频帧并通过 IPC 保存到指定路径(JPG 格式)
215
+ * @param {string} [outputPath] - 保存路径(可选,默认自动生成)
216
+ * @param {number} [quality=0.9] - JPG 质量 (0-1)
217
+ * @returns {Promise<{success: boolean, path?: string, error?: string}>}
218
+ */
219
+ async saveScreenshot(outputPath, quality = 0.9) {
220
+ if (!this.isReady || !this._canvas || !this.video) {
221
+ return { success: false, error: 'Stream not ready' }
222
+ }
223
+ if (!this._api || !this._api.saveScreenshot) {
224
+ return { success: false, error: 'IPC saveScreenshot not available' }
225
+ }
226
+ try {
227
+ this._canvasCtx.drawImage(this.video, 0, 0, this._videoWidth, this._videoHeight);
228
+ const dataUrl = this._canvas.toDataURL('image/jpeg', quality);
229
+ const base64 = dataUrl.substring(dataUrl.indexOf(',') + 1);
230
+ return await this._api.saveScreenshot(this.id, outputPath, base64)
231
+ } catch (e) {
232
+ return { success: false, error: e.message }
233
+ }
234
+ }
235
+
236
+ // ============ Internal ============
237
+
238
+ _initCanvas() {
239
+ this._canvas = document.createElement('canvas');
240
+ this._canvas.width = this._videoWidth;
241
+ this._canvas.height = this._videoHeight;
242
+ this._canvasCtx = this._canvas.getContext('2d');
243
+ }
244
+
245
+ _initMediaSource() {
246
+ if (!this.videoCodecString || !this.video) return
247
+
248
+ this.mediaSource = new MediaSource();
249
+ this.video.src = URL.createObjectURL(this.mediaSource);
250
+
251
+ this.mediaSource.addEventListener('sourceopen', () => {
252
+ if (this.mediaSource.readyState !== 'open') return
253
+ try {
254
+ const codecs = this.audioCodecString
255
+ ? this.videoCodecString + ',' + this.audioCodecString
256
+ : this.videoCodecString;
257
+ const mimeType = 'video/mp4; codecs="' + codecs + '"';
258
+ this.sourceBuffer = this.mediaSource.addSourceBuffer(mimeType);
259
+ this.sourceBuffer.addEventListener('updateend', () => this._processQueue());
260
+ this.isReady = true;
261
+ if (this.queue.length > 0) this._processQueue();
262
+ } catch (e) {
263
+ this._emit('error', e);
264
+ }
265
+ });
266
+
267
+ this.mediaSource.addEventListener('sourceclose', () => {
268
+ this.isReady = false;
269
+ this.sourceBuffer = null;
270
+ });
271
+ }
272
+
273
+ _processQueue() {
274
+ if (!this.isReady || !this.sourceBuffer) return
275
+ if (this.sourceBuffer.updating) return
276
+ if (this.queue.length === 0) return
277
+ try {
278
+ if (this.mediaSource && this.mediaSource.readyState === 'open') {
279
+ this.sourceBuffer.appendBuffer(this.queue.shift());
280
+ }
281
+ } catch (e) {
282
+ console.error('[VideoPlayer] processQueue error:', e);
283
+ this._emit('error', e);
284
+ }
285
+ }
286
+
287
+ _handleEvent(code, msg) {
288
+ this._emit('event', code, msg);
289
+
290
+ if (code === 1004) {
291
+ this._destroyMediaSource();
292
+ } else if (code === 3001) {
293
+ this.isRecording = true;
294
+ } else if (code === 3002 || code === 3003) {
295
+ this.isRecording = false;
296
+ }
297
+ }
298
+
299
+ _handleData(data) {
300
+ if (!this.isStarted) return
301
+ try {
302
+ this.queue.push(new Uint8Array(data).buffer);
303
+ if (this.isReady && this.sourceBuffer && !this.sourceBuffer.updating) {
304
+ this._processQueue();
305
+ }
306
+ } catch (e) {
307
+ console.error('[VideoPlayer] handleData error:', e);
308
+ this._emit('error', e);
309
+ }
310
+ }
311
+
312
+ _destroyMediaSource() {
313
+ if (this.sourceBuffer && this.mediaSource && this.mediaSource.readyState === 'open') {
314
+ try {
315
+ this.sourceBuffer.abort();
316
+ this.mediaSource.removeSourceBuffer(this.sourceBuffer);
317
+ } catch (e) { /* ignore */ }
318
+ }
319
+ if (this.video && this.video.src && this.video.src.startsWith('blob:')) {
320
+ URL.revokeObjectURL(this.video.src);
321
+ }
322
+ if (this.video) {
323
+ this.video.removeAttribute('src');
324
+ this.video.load();
325
+ }
326
+ this.sourceBuffer = null;
327
+ this.mediaSource = null;
328
+ }
329
+
330
+ _cleanupSubscriptions() {
331
+ if (this._unsubEvent) { this._unsubEvent(); this._unsubEvent = null; }
332
+ if (this._unsubInfo) { this._unsubInfo(); this._unsubInfo = null; }
333
+ if (this._unsubData) { this._unsubData(); this._unsubData = null; }
334
+ }
335
+
336
+ /**
337
+ * 启动每秒打印 video buffer 状态的循环
338
+ * 输出: 总缓冲秒数、缓冲分段数、领先当前播放点的秒数
339
+ */
340
+ _startBufferStats() {
341
+ this._stopBufferStats();
342
+ this._bufferStatsTimer = setInterval(() => {
343
+ const b = this.video && this.video.buffered;
344
+ if (!b || b.length === 0) {
345
+ console.log(`[VideoPlayer:${this.id}] buffer=0.00s, ranges=0`);
346
+ return
347
+ }
348
+ let total = 0;
349
+ for (let i = 0; i < b.length; i++) total += b.end(i) - b.start(i);
350
+ const ahead = Math.max(0, b.end(b.length - 1) - this.video.currentTime);
351
+ console.log(`[VideoPlayer:${this.id}] buffer=${total.toFixed(2)}s, ranges=${b.length}, ahead=${ahead.toFixed(2)}s`);
352
+ this._trimBuffer(total);
353
+ if (ahead > this._maxAhead) this._catchUp(ahead);
354
+ }, 1000);
355
+ }
356
+
357
+ _stopBufferStats() {
358
+ if (this._bufferStatsTimer) {
359
+ clearInterval(this._bufferStatsTimer);
360
+ this._bufferStatsTimer = null;
361
+ }
362
+ }
363
+
364
+ /**
365
+ * 清理已播放过的缓冲数据(直播场景)
366
+ * 策略:总缓冲超过 _maxBufferDuration 时,remove 掉 [bufferedStart, currentTime - _keepBehindDuration)
367
+ * @param {number} [totalDuration] - 预计算的总缓冲时长(秒),省略时内部重新计算
368
+ */
369
+ _trimBuffer(totalDuration) {
370
+ if (!this.sourceBuffer || !this.mediaSource || this.mediaSource.readyState !== 'open') return
371
+ // SourceBuffer 正在 append/remove 时不能再次操作
372
+ if (this.sourceBuffer.updating) return
373
+
374
+ const b = this.video.buffered;
375
+ if (!b || b.length === 0) return
376
+
377
+ const total = (typeof totalDuration === 'number')
378
+ ? totalDuration
379
+ : (() => { let s = 0; for (let i = 0; i < b.length; i++) s += b.end(i) - b.start(i); return s })();
380
+
381
+ // 未超阈值,不清理(避免每秒 remove 造成碎片)
382
+ if (total < this._maxBufferDuration) return
383
+
384
+ const currentTime = this.video.currentTime;
385
+ const removeEnd = currentTime - this._keepBehindDuration;
386
+ // 当前播放点还太靠前,保留区还未形成
387
+ if (removeEnd <= 0) return
388
+
389
+ const trimStart = b.start(0);
390
+ // removeEnd 必须严格大于 trimStart 才有意义
391
+ if (removeEnd <= trimStart) return
392
+
393
+ try {
394
+ this.sourceBuffer.remove(trimStart, removeEnd);
395
+ console.log(`[VideoPlayer:${this.id}] trim [${trimStart.toFixed(2)}, ${removeEnd.toFixed(2)}]`);
396
+ } catch (e) {
397
+ console.error(`[VideoPlayer:${this.id}] trimBuffer error:`, e);
398
+ }
399
+ }
400
+
401
+ /**
402
+ * 直播延迟追赶:seek 到 bufferedEnd - targetAhead
403
+ * 触发场景:截图、主线程阻塞、IPC 慢、网络突发等导致 ahead 累积
404
+ * @param {number} [currentAhead] - 预计算的 ahead 值,仅用于日志
405
+ */
406
+ _catchUp(currentAhead) {
407
+ if (!this.video || !this.video.buffered) return
408
+ const b = this.video.buffered;
409
+ if (b.length === 0) return
410
+
411
+ // 如果 video 被暂停(用户主动暂停),不追赶 —— 否则会强行拉回播放
412
+ if (this.video.paused) return
413
+
414
+ const bufferedEnd = b.end(b.length - 1);
415
+ const targetTime = bufferedEnd - this._targetAhead;
416
+ const currentTime = this.video.currentTime;
417
+
418
+ // target 必须严格大于 current 才有意义
419
+ if (targetTime <= currentTime) return
420
+ // 确保目标点在已缓冲范围内(seek 安全)
421
+ if (targetTime < b.start(0)) return
422
+
423
+ const jump = targetTime - currentTime;
424
+ const aheadLabel = (typeof currentAhead === 'number') ? ` (ahead was ${currentAhead.toFixed(2)}s)` : '';
425
+ try {
426
+ this.video.currentTime = targetTime;
427
+ console.log(`[VideoPlayer:${this.id}] catchUp: seek +${jump.toFixed(2)}s${aheadLabel}`);
428
+ } catch (e) {
429
+ console.error(`[VideoPlayer:${this.id}] catchUp error:`, e);
430
+ }
431
+ }
432
+ }
433
+
434
+ return VideoPlayer;
435
+
436
+ })();