nodeplayer-addon 0.3.0 → 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.
@@ -4,7 +4,435 @@
4
4
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.VideoPlayer = factory());
5
5
  })(this, (function () { 'use strict';
6
6
 
7
- class VideoPlayer{constructor(_0x4ccf50,_0x21946c,_0x2e497e={}){this['id']=_0x21946c,this['video']=_0x4ccf50,this['_api']=_0x2e497e['api']||typeof window!=='undef'+'ined'&&window['elect'+'ronAP'+'I']||null,this['_list'+'eners']={},this['media'+'Sourc'+'e']=null,this['sourc'+'eBuff'+'er']=null,this['queue']=[],this['isSta'+'rted']=![],this['isRea'+'dy']=![],this['isRec'+'ordin'+'g']=![],this['video'+'Codec'+'Strin'+'g']=null,this['audio'+'Codec'+'Strin'+'g']=null,this['_vide'+'oWidt'+'h']=0x0,this['_vide'+'oHeig'+'ht']=0x0,this['_canv'+'as']=null,this['_canv'+'asCtx']=null,this['_unsu'+'bEven'+'t']=null,this['_unsu'+'bInfo']=null,this['_unsu'+'bData']=null,this['_buff'+'erSta'+'tsTim'+'er']=null,this['_maxB'+'uffer'+'Durat'+'ion']=_0x2e497e['maxBu'+'fferD'+'urati'+'on']||0x1e,this['_keep'+'Behin'+'dDura'+'tion']=_0x2e497e['keepB'+'ehind'+'Durat'+'ion']||0x5,this['_targ'+'etAhe'+'ad']=_0x2e497e['targe'+'tAhea'+'d']!=null?_0x2e497e['targe'+'tAhea'+'d']:0.3,this['_maxA'+'head']=_0x2e497e['maxAh'+'ead']!=null?_0x2e497e['maxAh'+'ead']:0x3;}['on'](_0x42449d,_0x48b3d8){if(!this['_list'+'eners'][_0x42449d])this['_list'+'eners'][_0x42449d]=new Set();return this['_list'+'eners'][_0x42449d]['add'](_0x48b3d8),this;}['off'](_0x27ea22,_0x55f038){if(this['_list'+'eners'][_0x27ea22])this['_list'+'eners'][_0x27ea22]['delet'+'e'](_0x55f038);return this;}['_emit'](_0x2ad5bc,..._0x176ce3){const _0x401a51=this['_list'+'eners'][_0x2ad5bc];if(_0x401a51){for(const _0x1cca17 of _0x401a51)_0x1cca17(..._0x176ce3);}}async['start'](_0x5b1581){if(this['isSta'+'rted'])return;const _0x461c8b=await this['_api']['creat'+'ePlay'+'er'](this['id']);if(!_0x461c8b['succe'+'ss']){this['_emit']('error',new Error(_0x461c8b['error']));return;}this['_unsu'+'bEven'+'t']=this['_api']['onEve'+'nt'](this['id'],_0x2b7b21=>{this['_hand'+'leEve'+'nt'](_0x2b7b21['code'],_0x2b7b21['msg']);}),this['_unsu'+'bInfo']=this['_api']['onInf'+'o'](this['id'],_0x1dcc8b=>{this['video'+'Codec'+'Strin'+'g']=_0x1dcc8b['video']?_0x1dcc8b['video']['codec'+'Strin'+'g']:null,this['audio'+'Codec'+'Strin'+'g']=_0x1dcc8b['audio']?_0x1dcc8b['audio']['codec'+'Strin'+'g']:null,_0x1dcc8b['video']&&_0x1dcc8b['video']['width']&&_0x1dcc8b['video']['heigh'+'t']&&(this['_vide'+'oWidt'+'h']=_0x1dcc8b['video']['width'],this['_vide'+'oHeig'+'ht']=_0x1dcc8b['video']['heigh'+'t'],this['_init'+'Canva'+'s']()),this['_init'+'Media'+'Sourc'+'e']();}),this['_unsu'+'bData']=this['_api']['onDat'+'a'](this['id'],_0x4382a5=>{this['_hand'+'leDat'+'a'](_0x4382a5);});const _0x2f4346=await this['_api']['start'+'Playe'+'r'](this['id'],_0x5b1581);if(!_0x2f4346['succe'+'ss']){this['_emit']('error',new Error(_0x2f4346['error'])),this['_clea'+'nupSu'+'bscri'+'ption'+'s']();return;}this['isSta'+'rted']=!![];}async['stop'](){if(!this['isSta'+'rted'])return;this['isSta'+'rted']=![],this['isRea'+'dy']=![],this['_clea'+'nupSu'+'bscri'+'ption'+'s']();try{await this['_api']['stopP'+'layer'](this['id']),await this['_api']['destr'+'oyPla'+'yer'](this['id']);}catch(_0x29e6bc){}this['_dest'+'royMe'+'diaSo'+'urce'](),this['_canv'+'as']=null,this['_canv'+'asCtx']=null,this['_vide'+'oWidt'+'h']=0x0,this['_vide'+'oHeig'+'ht']=0x0,this['queue']=[],this['isRec'+'ordin'+'g']=![];}async['start'+'Recor'+'d'](_0x260385){if(!this['isSta'+'rted'])return {'success':![],'error':'Playe'+'r\x20not'+'\x20star'+'ted'};try{const _0x435fa1=await this['_api']['start'+'Recor'+'d'](this['id'],_0x260385);if(_0x435fa1['succe'+'ss'])this['isRec'+'ordin'+'g']=!![];return _0x435fa1;}catch(_0x58921d){return {'success':![],'error':_0x58921d['messa'+'ge']};}}async['stopR'+'ecord'](){if(!this['isSta'+'rted'])return {'success':![],'error':'Playe'+'r\x20not'+'\x20star'+'ted'};try{const _0x223b34=await this['_api']['stopR'+'ecord'](this['id']);if(_0x223b34['succe'+'ss'])this['isRec'+'ordin'+'g']=![];return _0x223b34;}catch(_0x1609f2){return {'success':![],'error':_0x1609f2['messa'+'ge']};}}['captu'+'reScr'+'eensh'+'ot'](_0x4c6fd7=0.9){if(!this['isRea'+'dy']||!this['_canv'+'as']||!this['video'])return null;return this['_canv'+'asCtx']['drawI'+'mage'](this['video'],0x0,0x0,this['_vide'+'oWidt'+'h'],this['_vide'+'oHeig'+'ht']),this['_canv'+'as']['toDat'+'aURL']('image'+'/jpeg',_0x4c6fd7);}async['saveS'+'creen'+'shot'](_0x2320ac,_0x300062=0.9){if(!this['isRea'+'dy']||!this['_canv'+'as']||!this['video'])return {'success':![],'error':'Strea'+'m\x20not'+'\x20read'+'y'};if(!this['_api']||!this['_api']['saveS'+'creen'+'shot'])return {'success':![],'error':'IPC\x20s'+'aveSc'+'reens'+'hot\x20n'+'ot\x20av'+'ailab'+'le'};try{this['_canv'+'asCtx']['drawI'+'mage'](this['video'],0x0,0x0,this['_vide'+'oWidt'+'h'],this['_vide'+'oHeig'+'ht']);const _0x5f1991=this['_canv'+'as']['toDat'+'aURL']('image'+'/jpeg',_0x300062),_0x3e4160=_0x5f1991['subst'+'ring'](_0x5f1991['index'+'Of'](',')+0x1);return await this['_api']['saveS'+'creen'+'shot'](this['id'],_0x2320ac,_0x3e4160);}catch(_0x17fdfa){return {'success':![],'error':_0x17fdfa['messa'+'ge']};}}['_init'+'Canva'+'s'](){this['_canv'+'as']=document['creat'+'eElem'+'ent']('canva'+'s'),this['_canv'+'as']['width']=this['_vide'+'oWidt'+'h'],this['_canv'+'as']['heigh'+'t']=this['_vide'+'oHeig'+'ht'],this['_canv'+'asCtx']=this['_canv'+'as']['getCo'+'ntext']('2d');}['_init'+'Media'+'Sourc'+'e'](){if(!this['video'+'Codec'+'Strin'+'g']||!this['video'])return;this['media'+'Sourc'+'e']=new MediaSource(),this['video']['src']=URL['creat'+'eObje'+'ctURL'](this['media'+'Sourc'+'e']),this['media'+'Sourc'+'e']['addEv'+'entLi'+'stene'+'r']('sourc'+'eopen',()=>{if(this['media'+'Sourc'+'e']['ready'+'State']!=='open')return;try{const _0x415439=this['audio'+'Codec'+'Strin'+'g']?this['video'+'Codec'+'Strin'+'g']+','+this['audio'+'Codec'+'Strin'+'g']:this['video'+'Codec'+'Strin'+'g'],_0x2a0ba1='video'+'/mp4;'+'\x20code'+'cs=\x22'+_0x415439+'\x22';this['sourc'+'eBuff'+'er']=this['media'+'Sourc'+'e']['addSo'+'urceB'+'uffer'](_0x2a0ba1),this['sourc'+'eBuff'+'er']['addEv'+'entLi'+'stene'+'r']('updat'+'eend',()=>this['_proc'+'essQu'+'eue']()),this['isRea'+'dy']=!![];if(this['queue']['lengt'+'h']>0x0)this['_proc'+'essQu'+'eue']();}catch(_0x256d93){this['_emit']('error',_0x256d93);}}),this['media'+'Sourc'+'e']['addEv'+'entLi'+'stene'+'r']('sourc'+'eclos'+'e',()=>{this['isRea'+'dy']=![],this['sourc'+'eBuff'+'er']=null;});}['_proc'+'essQu'+'eue'](){if(!this['isRea'+'dy']||!this['sourc'+'eBuff'+'er'])return;if(this['sourc'+'eBuff'+'er']['updat'+'ing'])return;if(this['queue']['lengt'+'h']===0x0)return;try{this['media'+'Sourc'+'e']&&this['media'+'Sourc'+'e']['ready'+'State']==='open'&&this['sourc'+'eBuff'+'er']['appen'+'dBuff'+'er'](this['queue']['shift']());}catch(_0x4185ce){console['error']('[Vide'+'oPlay'+'er]\x20p'+'roces'+'sQueu'+'e\x20err'+'or:',_0x4185ce),this['_emit']('error',_0x4185ce);}}['_hand'+'leEve'+'nt'](_0x2deb75,_0x29f934){this['_emit']('event',_0x2deb75,_0x29f934);if(_0x2deb75===0x3ec)this['_dest'+'royMe'+'diaSo'+'urce']();else {if(_0x2deb75===0xbb9)this['isRec'+'ordin'+'g']=!![];else (_0x2deb75===0xbba||_0x2deb75===0xbbb)&&(this['isRec'+'ordin'+'g']=![]);}}['_hand'+'leDat'+'a'](_0x76d1c2){if(!this['isSta'+'rted'])return;try{this['queue']['push'](new Uint8Array(_0x76d1c2)['buffe'+'r']),this['isRea'+'dy']&&this['sourc'+'eBuff'+'er']&&!this['sourc'+'eBuff'+'er']['updat'+'ing']&&this['_proc'+'essQu'+'eue']();}catch(_0x12a0b1){console['error']('[Vide'+'oPlay'+'er]\x20h'+'andle'+'Data\x20'+'error'+':',_0x12a0b1),this['_emit']('error',_0x12a0b1);}}['_dest'+'royMe'+'diaSo'+'urce'](){if(this['sourc'+'eBuff'+'er']&&this['media'+'Sourc'+'e']&&this['media'+'Sourc'+'e']['ready'+'State']==='open')try{this['sourc'+'eBuff'+'er']['abort'](),this['media'+'Sourc'+'e']['remov'+'eSour'+'ceBuf'+'fer'](this['sourc'+'eBuff'+'er']);}catch(_0x30dedb){}this['video']&&this['video']['src']&&this['video']['src']['start'+'sWith']('blob:')&&URL['revok'+'eObje'+'ctURL'](this['video']['src']),this['video']&&(this['video']['remov'+'eAttr'+'ibute']('src'),this['video']['load']()),this['sourc'+'eBuff'+'er']=null,this['media'+'Sourc'+'e']=null;}['_clea'+'nupSu'+'bscri'+'ption'+'s'](){this['_unsu'+'bEven'+'t']&&(this['_unsu'+'bEven'+'t'](),this['_unsu'+'bEven'+'t']=null),this['_unsu'+'bInfo']&&(this['_unsu'+'bInfo'](),this['_unsu'+'bInfo']=null),this['_unsu'+'bData']&&(this['_unsu'+'bData'](),this['_unsu'+'bData']=null);}['_star'+'tBuff'+'erSta'+'ts'](){this['_stop'+'Buffe'+'rStat'+'s'](),this['_buff'+'erSta'+'tsTim'+'er']=setInterval(()=>{const _0x2117dc=this['video']&&this['video']['buffe'+'red'];if(!_0x2117dc||_0x2117dc['lengt'+'h']===0x0){console['log']('[Vide'+'oPlay'+'er:'+this['id']+(']\x20buf'+'fer=0'+'.00s,'+'\x20rang'+'es=0'));return;}let _0x50fbe0=0x0;for(let _0x40482b=0x0;_0x40482b<_0x2117dc['lengt'+'h'];_0x40482b++)_0x50fbe0+=_0x2117dc['end'](_0x40482b)-_0x2117dc['start'](_0x40482b);const _0x1a5a18=Math['max'](0x0,_0x2117dc['end'](_0x2117dc['lengt'+'h']-0x1)-this['video']['curre'+'ntTim'+'e']);console['log']('[Vide'+'oPlay'+'er:'+this['id']+(']\x20buf'+'fer=')+_0x50fbe0['toFix'+'ed'](0x2)+('s,\x20ra'+'nges=')+_0x2117dc['lengt'+'h']+(',\x20ahe'+'ad=')+_0x1a5a18['toFix'+'ed'](0x2)+'s'),this['_trim'+'Buffe'+'r'](_0x50fbe0);if(_0x1a5a18>this['_maxA'+'head'])this['_catc'+'hUp'](_0x1a5a18);},0x3e8);}['_stop'+'Buffe'+'rStat'+'s'](){this['_buff'+'erSta'+'tsTim'+'er']&&(clearInterval(this['_buff'+'erSta'+'tsTim'+'er']),this['_buff'+'erSta'+'tsTim'+'er']=null);}['_trim'+'Buffe'+'r'](_0x1771f8){if(!this['sourc'+'eBuff'+'er']||!this['media'+'Sourc'+'e']||this['media'+'Sourc'+'e']['ready'+'State']!=='open')return;if(this['sourc'+'eBuff'+'er']['updat'+'ing'])return;const _0x1dc7fe=this['video']['buffe'+'red'];if(!_0x1dc7fe||_0x1dc7fe['lengt'+'h']===0x0)return;const _0x4a01ef=typeof _0x1771f8==='numbe'+'r'?_0x1771f8:((()=>{let _0x142e18=0x0;for(let _0x4787b7=0x0;_0x4787b7<_0x1dc7fe['lengt'+'h'];_0x4787b7++)_0x142e18+=_0x1dc7fe['end'](_0x4787b7)-_0x1dc7fe['start'](_0x4787b7);return _0x142e18;})());if(_0x4a01ef<this['_maxB'+'uffer'+'Durat'+'ion'])return;const _0x32635c=this['video']['curre'+'ntTim'+'e'],_0x150d75=_0x32635c-this['_keep'+'Behin'+'dDura'+'tion'];if(_0x150d75<=0x0)return;const _0x189187=_0x1dc7fe['start'](0x0);if(_0x150d75<=_0x189187)return;try{this['sourc'+'eBuff'+'er']['remov'+'e'](_0x189187,_0x150d75),console['log']('[Vide'+'oPlay'+'er:'+this['id']+(']\x20tri'+'m\x20[')+_0x189187['toFix'+'ed'](0x2)+',\x20'+_0x150d75['toFix'+'ed'](0x2)+']');}catch(_0x289255){console['error']('[Vide'+'oPlay'+'er:'+this['id']+(']\x20tri'+'mBuff'+'er\x20er'+'ror:'),_0x289255);}}['_catc'+'hUp'](_0x5fe849){if(!this['video']||!this['video']['buffe'+'red'])return;const _0x70b9db=this['video']['buffe'+'red'];if(_0x70b9db['lengt'+'h']===0x0)return;if(this['video']['pause'+'d'])return;const _0x2a53e3=_0x70b9db['end'](_0x70b9db['lengt'+'h']-0x1),_0x41c373=_0x2a53e3-this['_targ'+'etAhe'+'ad'],_0xdb224b=this['video']['curre'+'ntTim'+'e'];if(_0x41c373<=_0xdb224b)return;if(_0x41c373<_0x70b9db['start'](0x0))return;const _0x34a350=_0x41c373-_0xdb224b,_0x5e28d4=typeof _0x5fe849==='numbe'+'r'?'\x20(ahe'+'ad\x20wa'+'s\x20'+_0x5fe849['toFix'+'ed'](0x2)+'s)':'';try{this['video']['curre'+'ntTim'+'e']=_0x41c373,console['log']('[Vide'+'oPlay'+'er:'+this['id']+(']\x20cat'+'chUp:'+'\x20seek'+'\x20+')+_0x34a350['toFix'+'ed'](0x2)+'s'+_0x5e28d4);}catch(_0x100722){console['error']('[Vide'+'oPlay'+'er:'+this['id']+(']\x20cat'+'chUp\x20'+'error'+':'),_0x100722);}}}
7
+ /**
8
+ * VideoPlayer — MediaSource Extension based video player for Electron renderer.
9
+ *
10
+ * Works with the IPC bridge set up by NodePlayer.registerIpc() in the main process
11
+ * and the preload script that exposes window.electronAPI.
12
+ *
13
+ * Usage (ESM in React/Vue):
14
+ * import VideoPlayer from 'nodeplayer-addon/video-player'
15
+ * const player = new VideoPlayer(videoEl, 'my-id')
16
+ * player.on('event', (code, msg) => { ... })
17
+ * player.on('error', (err) => { ... })
18
+ * await player.start('rtsp://...')
19
+ *
20
+ * Usage (UMD in HTML):
21
+ * <script src="video-player.umd.js"></script>
22
+ * <script>
23
+ * const player = new VideoPlayer(videoEl, 'my-id')
24
+ * player.on('event', (code, msg) => { ... })
25
+ * player.on('error', (err) => { ... })
26
+ * player.start('rtsp://...')
27
+ * </script>
28
+ */
29
+ class VideoPlayer {
30
+ /**
31
+ * @param {HTMLVideoElement} video - The <video> element to render into
32
+ * @param {string} id - Unique player identifier (used for IPC channels)
33
+ * @param {object} [options]
34
+ * @param {object} [options.api] - IPC bridge (default: window.electronAPI)
35
+ * @param {number} [options.maxBufferDuration=15] - 直播缓冲上限(秒),超过则触发清理
36
+ * @param {number} [options.keepBehindDuration=5] - 清理时保留当前播放点之前的秒数
37
+ * @param {number} [options.targetAhead=0.5] - 追赶后的目标 ahead 值(秒)
38
+ * @param {number} [options.maxAhead=3] - ahead 超过此值时触发 seek 追赶
39
+ */
40
+ constructor(video, id, options = {}) {
41
+ this.id = id;
42
+ this.video = video;
43
+ this._api = options.api || (typeof window !== 'undefined' && window.electronAPI) || null;
44
+ this._listeners = {};
45
+
46
+ this.mediaSource = null;
47
+ this.sourceBuffer = null;
48
+ this.queue = [];
49
+ this.isStarted = false;
50
+ this.isReady = false;
51
+ this.isRecording = false;
52
+ this.videoCodecString = null;
53
+ this.audioCodecString = null;
54
+
55
+ this._videoWidth = 0;
56
+ this._videoHeight = 0;
57
+ this._canvas = null;
58
+ this._canvasCtx = null;
59
+
60
+ this._unsubEvent = null;
61
+ this._unsubInfo = null;
62
+ this._unsubData = null;
63
+ this._bufferStatsTimer = null;
64
+
65
+ // 直播场景的缓冲控制(主动清理已播放数据,避免无限增长)
66
+ // 触发清理的总缓冲阈值
67
+ this._maxBufferDuration = options.maxBufferDuration || 30;
68
+ // 清理时保留当前播放点之前的秒数(抗抖动 + 允许短暂回看)
69
+ this._keepBehindDuration = options.keepBehindDuration || 5;
70
+
71
+ // 直播延迟追赶(任何原因导致 ahead 累积过大时,seek 到接近 buffer 末端)
72
+ // 追赶后的目标 ahead 值
73
+ this._targetAhead = options.targetAhead != null ? options.targetAhead : 0.3;
74
+ // ahead 超过此阈值时触发追赶(截图/卡顿/IPC 慢等场景)
75
+ this._maxAhead = options.maxAhead != null ? options.maxAhead : 3;
76
+ }
77
+
78
+ /**
79
+ * 注册事件监听器
80
+ * @param {'event'|'error'} event - 事件名
81
+ * @param {function} fn - 'event': (code: number, msg: string) => void; 'error': (err: Error) => void
82
+ * @returns {this} 支持链式调用
83
+ */
84
+ on(event, fn) {
85
+ if (!this._listeners[event]) this._listeners[event] = new Set();
86
+ this._listeners[event].add(fn);
87
+ return this
88
+ }
89
+
90
+ /**
91
+ * 移除事件监听器
92
+ * @param {'event'|'error'} event - 事件名
93
+ * @param {function} fn - 要移除的监听函数
94
+ * @returns {this}
95
+ */
96
+ off(event, fn) {
97
+ if (this._listeners[event]) this._listeners[event].delete(fn);
98
+ return this
99
+ }
100
+
101
+ _emit(event, ...args) {
102
+ const set = this._listeners[event];
103
+ if (set) for (const fn of set) fn(...args);
104
+ }
105
+
106
+ /**
107
+ * 启动播放
108
+ * @param {string} url - RTSP/RTMP/KMP 地址
109
+ */
110
+ async start(url) {
111
+ if (this.isStarted) return
112
+
113
+ const createResult = await this._api.createPlayer(this.id);
114
+ if (!createResult.success) {
115
+ this._emit('error', new Error(createResult.error));
116
+ return
117
+ }
118
+
119
+ this._unsubEvent = this._api.onEvent(this.id, (data) => {
120
+ this._handleEvent(data.code, data.msg);
121
+ });
122
+
123
+ this._unsubInfo = this._api.onInfo(this.id, (info) => {
124
+ this.videoCodecString = info.video ? info.video.codecString : null;
125
+ this.audioCodecString = info.audio ? info.audio.codecString : null;
126
+ if (info.video && info.video.width && info.video.height) {
127
+ this._videoWidth = info.video.width;
128
+ this._videoHeight = info.video.height;
129
+ this._initCanvas();
130
+ }
131
+ this._initMediaSource();
132
+ });
133
+
134
+ this._unsubData = this._api.onData(this.id, (data) => {
135
+ this._handleData(data);
136
+ });
137
+
138
+ const startResult = await this._api.startPlayer(this.id, url);
139
+ if (!startResult.success) {
140
+ this._emit('error', new Error(startResult.error));
141
+ this._cleanupSubscriptions();
142
+ return
143
+ }
144
+
145
+ this.isStarted = true;
146
+ // this._startBufferStats()
147
+ }
148
+
149
+ /**
150
+ * 停止播放并释放资源
151
+ */
152
+ async stop() {
153
+ if (!this.isStarted) return
154
+ this.isStarted = false;
155
+ this.isReady = false;
156
+
157
+ // this._stopBufferStats()
158
+ this._cleanupSubscriptions();
159
+
160
+ try {
161
+ await this._api.stopPlayer(this.id);
162
+ await this._api.destroyPlayer(this.id);
163
+ } catch (e) { /* ignore */ }
164
+
165
+ this._destroyMediaSource();
166
+ this._canvas = null;
167
+ this._canvasCtx = null;
168
+ this._videoWidth = 0;
169
+ this._videoHeight = 0;
170
+ this.queue = [];
171
+ this.isRecording = false;
172
+ }
173
+
174
+ /**
175
+ * 开始录像
176
+ * @param {string} [outputPath] - 输出文件路径(可选,由主进程自动生成)
177
+ * @returns {Promise<{success: boolean, path?: string, error?: string}>}
178
+ */
179
+ async startRecord(outputPath) {
180
+ if (!this.isStarted) return { success: false, error: 'Player not started' }
181
+ try {
182
+ const result = await this._api.startRecord(this.id, outputPath);
183
+ if (result.success) this.isRecording = true;
184
+ return result
185
+ } catch (e) {
186
+ return { success: false, error: e.message }
187
+ }
188
+ }
189
+
190
+ /**
191
+ * 停止录像
192
+ * @returns {Promise<{success: boolean, error?: string}>}
193
+ */
194
+ async stopRecord() {
195
+ if (!this.isStarted) return { success: false, error: 'Player not started' }
196
+ try {
197
+ const result = await this._api.stopRecord(this.id);
198
+ if (result.success) this.isRecording = false;
199
+ return result
200
+ } catch (e) {
201
+ return { success: false, error: e.message }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * 截取当前视频帧,返回 JPG 格式的 data URL
207
+ * @param {number} [quality=0.9] - JPG 质量 (0-1)
208
+ * @returns {string|null} data URL (image/jpeg),未就绪时返回 null
209
+ */
210
+ captureScreenshot(quality = 0.9) {
211
+ if (!this.isReady || !this._canvas || !this.video) return null
212
+ this._canvasCtx.drawImage(this.video, 0, 0, this._videoWidth, this._videoHeight);
213
+ return this._canvas.toDataURL('image/jpeg', quality)
214
+ }
215
+
216
+ /**
217
+ * 截取当前视频帧并通过 IPC 保存到指定路径(JPG 格式)
218
+ * @param {string} [outputPath] - 保存路径(可选,默认自动生成)
219
+ * @param {number} [quality=0.9] - JPG 质量 (0-1)
220
+ * @returns {Promise<{success: boolean, path?: string, error?: string}>}
221
+ */
222
+ async saveScreenshot(outputPath, quality = 0.9) {
223
+ if (!this.isReady || !this._canvas || !this.video) {
224
+ return { success: false, error: 'Stream not ready' }
225
+ }
226
+ if (!this._api || !this._api.saveScreenshot) {
227
+ return { success: false, error: 'IPC saveScreenshot not available' }
228
+ }
229
+ try {
230
+ this._canvasCtx.drawImage(this.video, 0, 0, this._videoWidth, this._videoHeight);
231
+ const dataUrl = this._canvas.toDataURL('image/jpeg', quality);
232
+ const base64 = dataUrl.substring(dataUrl.indexOf(',') + 1);
233
+ return await this._api.saveScreenshot(this.id, outputPath, base64)
234
+ } catch (e) {
235
+ return { success: false, error: e.message }
236
+ }
237
+ }
238
+
239
+ // ============ Internal ============
240
+
241
+ _initCanvas() {
242
+ this._canvas = document.createElement('canvas');
243
+ this._canvas.width = this._videoWidth;
244
+ this._canvas.height = this._videoHeight;
245
+ this._canvasCtx = this._canvas.getContext('2d');
246
+ }
247
+
248
+ _initMediaSource() {
249
+ if (!this.videoCodecString || !this.video) return
250
+
251
+ this.mediaSource = new MediaSource();
252
+ this.video.src = URL.createObjectURL(this.mediaSource);
253
+
254
+ this.mediaSource.addEventListener('sourceopen', () => {
255
+ if (this.mediaSource.readyState !== 'open') return
256
+ try {
257
+ const codecs = this.audioCodecString
258
+ ? this.videoCodecString + ',' + this.audioCodecString
259
+ : this.videoCodecString;
260
+ const mimeType = 'video/mp4; codecs="' + codecs + '"';
261
+ this.sourceBuffer = this.mediaSource.addSourceBuffer(mimeType);
262
+ this.sourceBuffer.addEventListener('updateend', () => this._processQueue());
263
+ this.isReady = true;
264
+ if (this.queue.length > 0) this._processQueue();
265
+ } catch (e) {
266
+ this._emit('error', e);
267
+ }
268
+ });
269
+
270
+ this.mediaSource.addEventListener('sourceclose', () => {
271
+ this.isReady = false;
272
+ this.sourceBuffer = null;
273
+ });
274
+ }
275
+
276
+ _processQueue() {
277
+ if (!this.isReady || !this.sourceBuffer) return
278
+ if (this.sourceBuffer.updating) return
279
+ if (this.queue.length === 0) return
280
+ try {
281
+ if (this.mediaSource && this.mediaSource.readyState === 'open') {
282
+ this.sourceBuffer.appendBuffer(this.queue.shift());
283
+ }
284
+ } catch (e) {
285
+ console.error('[VideoPlayer] processQueue error:', e);
286
+ this._emit('error', e);
287
+ }
288
+ }
289
+
290
+ _handleEvent(code, msg) {
291
+ this._emit('event', code, msg);
292
+
293
+ if (code === 1004) {
294
+ this._destroyMediaSource();
295
+ } else if (code === 3001) {
296
+ this.isRecording = true;
297
+ } else if (code === 3002 || code === 3003) {
298
+ this.isRecording = false;
299
+ }
300
+ }
301
+
302
+ _handleData(data) {
303
+ if (!this.isStarted) return
304
+ try {
305
+ this.queue.push(new Uint8Array(data).buffer);
306
+ if (this.isReady && this.sourceBuffer && !this.sourceBuffer.updating) {
307
+ this._processQueue();
308
+ }
309
+ } catch (e) {
310
+ console.error('[VideoPlayer] handleData error:', e);
311
+ this._emit('error', e);
312
+ }
313
+ }
314
+
315
+ _destroyMediaSource() {
316
+ if (this.sourceBuffer && this.mediaSource && this.mediaSource.readyState === 'open') {
317
+ try {
318
+ this.sourceBuffer.abort();
319
+ this.mediaSource.removeSourceBuffer(this.sourceBuffer);
320
+ } catch (e) { /* ignore */ }
321
+ }
322
+ if (this.video && this.video.src && this.video.src.startsWith('blob:')) {
323
+ URL.revokeObjectURL(this.video.src);
324
+ }
325
+ if (this.video) {
326
+ this.video.removeAttribute('src');
327
+ this.video.load();
328
+ }
329
+ this.sourceBuffer = null;
330
+ this.mediaSource = null;
331
+ }
332
+
333
+ _cleanupSubscriptions() {
334
+ if (this._unsubEvent) { this._unsubEvent(); this._unsubEvent = null; }
335
+ if (this._unsubInfo) { this._unsubInfo(); this._unsubInfo = null; }
336
+ if (this._unsubData) { this._unsubData(); this._unsubData = null; }
337
+ }
338
+
339
+ /**
340
+ * 启动每秒打印 video buffer 状态的循环
341
+ * 输出: 总缓冲秒数、缓冲分段数、领先当前播放点的秒数
342
+ */
343
+ _startBufferStats() {
344
+ this._stopBufferStats();
345
+ this._bufferStatsTimer = setInterval(() => {
346
+ const b = this.video && this.video.buffered;
347
+ if (!b || b.length === 0) {
348
+ console.log(`[VideoPlayer:${this.id}] buffer=0.00s, ranges=0`);
349
+ return
350
+ }
351
+ let total = 0;
352
+ for (let i = 0; i < b.length; i++) total += b.end(i) - b.start(i);
353
+ const ahead = Math.max(0, b.end(b.length - 1) - this.video.currentTime);
354
+ console.log(`[VideoPlayer:${this.id}] buffer=${total.toFixed(2)}s, ranges=${b.length}, ahead=${ahead.toFixed(2)}s`);
355
+ this._trimBuffer(total);
356
+ if (ahead > this._maxAhead) this._catchUp(ahead);
357
+ }, 1000);
358
+ }
359
+
360
+ _stopBufferStats() {
361
+ if (this._bufferStatsTimer) {
362
+ clearInterval(this._bufferStatsTimer);
363
+ this._bufferStatsTimer = null;
364
+ }
365
+ }
366
+
367
+ /**
368
+ * 清理已播放过的缓冲数据(直播场景)
369
+ * 策略:总缓冲超过 _maxBufferDuration 时,remove 掉 [bufferedStart, currentTime - _keepBehindDuration)
370
+ * @param {number} [totalDuration] - 预计算的总缓冲时长(秒),省略时内部重新计算
371
+ */
372
+ _trimBuffer(totalDuration) {
373
+ if (!this.sourceBuffer || !this.mediaSource || this.mediaSource.readyState !== 'open') return
374
+ // SourceBuffer 正在 append/remove 时不能再次操作
375
+ if (this.sourceBuffer.updating) return
376
+
377
+ const b = this.video.buffered;
378
+ if (!b || b.length === 0) return
379
+
380
+ const total = (typeof totalDuration === 'number')
381
+ ? totalDuration
382
+ : (() => { let s = 0; for (let i = 0; i < b.length; i++) s += b.end(i) - b.start(i); return s })();
383
+
384
+ // 未超阈值,不清理(避免每秒 remove 造成碎片)
385
+ if (total < this._maxBufferDuration) return
386
+
387
+ const currentTime = this.video.currentTime;
388
+ const removeEnd = currentTime - this._keepBehindDuration;
389
+ // 当前播放点还太靠前,保留区还未形成
390
+ if (removeEnd <= 0) return
391
+
392
+ const trimStart = b.start(0);
393
+ // removeEnd 必须严格大于 trimStart 才有意义
394
+ if (removeEnd <= trimStart) return
395
+
396
+ try {
397
+ this.sourceBuffer.remove(trimStart, removeEnd);
398
+ console.log(`[VideoPlayer:${this.id}] trim [${trimStart.toFixed(2)}, ${removeEnd.toFixed(2)}]`);
399
+ } catch (e) {
400
+ console.error(`[VideoPlayer:${this.id}] trimBuffer error:`, e);
401
+ }
402
+ }
403
+
404
+ /**
405
+ * 直播延迟追赶:seek 到 bufferedEnd - targetAhead
406
+ * 触发场景:截图、主线程阻塞、IPC 慢、网络突发等导致 ahead 累积
407
+ * @param {number} [currentAhead] - 预计算的 ahead 值,仅用于日志
408
+ */
409
+ _catchUp(currentAhead) {
410
+ if (!this.video || !this.video.buffered) return
411
+ const b = this.video.buffered;
412
+ if (b.length === 0) return
413
+
414
+ // 如果 video 被暂停(用户主动暂停),不追赶 —— 否则会强行拉回播放
415
+ if (this.video.paused) return
416
+
417
+ const bufferedEnd = b.end(b.length - 1);
418
+ const targetTime = bufferedEnd - this._targetAhead;
419
+ const currentTime = this.video.currentTime;
420
+
421
+ // target 必须严格大于 current 才有意义
422
+ if (targetTime <= currentTime) return
423
+ // 确保目标点在已缓冲范围内(seek 安全)
424
+ if (targetTime < b.start(0)) return
425
+
426
+ const jump = targetTime - currentTime;
427
+ const aheadLabel = (typeof currentAhead === 'number') ? ` (ahead was ${currentAhead.toFixed(2)}s)` : '';
428
+ try {
429
+ this.video.currentTime = targetTime;
430
+ console.log(`[VideoPlayer:${this.id}] catchUp: seek +${jump.toFixed(2)}s${aheadLabel}`);
431
+ } catch (e) {
432
+ console.error(`[VideoPlayer:${this.id}] catchUp error:`, e);
433
+ }
434
+ }
435
+ }
8
436
 
9
437
  return VideoPlayer;
10
438
 
@@ -0,0 +1,26 @@
1
+ NodePlayerAddon 是一款专为 Electron 开发的播放器插件,基于 NAPI C++ 构建,能够轻松播放 RTSP、RTMP、KMP 等 Electron 原生环境无法直接支持的网络视频协议。该插件全面支持 Windows、Linux 和 macOS 操作系统,并兼容 x64、ARM64 及 Loong64 等多种硬件架构。无论您是在桌面应用中集成实时监控、直播流,还是其他视频能力,NodePlayerAddon 都将成为您开发 Electron 桌面应用的得力助手。
2
+
3
+ ## 功能特色
4
+ - 支持系统:Windows,Linux,MacOS
5
+ - 支持架构:amd64,arm64,loong64, riscv64
6
+ - 支持协议:RTSP,RTMP,KMP,HTTP-FLV
7
+ - 支持编码:H.264/H.265/G.711/G.726/MP2/AAC
8
+ - 播放中截图,录像
9
+ - 多画面,全屏播放
10
+ - 硬件解码加速
11
+
12
+ ## 快速路由
13
+
14
+ | File | Description | Priority |
15
+ |------|-------------|----------|
16
+ | [quick-start.md](./quick-start.md) | Minimal Electron integration (Electron Forge + HTML/UMD) | start here |
17
+ | [react-frontend.md](./react-frontend.md) | React frontend with create-electron scaffold | on demand |
18
+ | [vue-frontend.md](./vue-frontend.md) | Vue frontend with create-electron scaffold | on demand |
19
+
20
+ ## 应用案例
21
+
22
+ ## 商务服务
23
+ 产品使用需商用授权,请联系客服
24
+ QQ:281269007
25
+ Email : service@nodemedia.cn
26
+