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.
- package/dist/index.cjs +450 -5
- package/dist/index.mjs +450 -5
- package/dist/video-player.js +436 -0
- package/dist/video-player.mjs +429 -1
- package/dist/video-player.umd.js +429 -1
- package/docs/introduction.md +26 -0
- package/docs/quick-start.md +265 -0
- package/docs/react-frontend.md +328 -0
- package/docs/vue-frontend.md +330 -0
- package/package.json +7 -6
- package/SKILL.md +0 -684
package/dist/video-player.mjs
CHANGED
|
@@ -1,3 +1,431 @@
|
|
|
1
|
-
class VideoPlayer{constructor(_0x151a72,_0x3908d0,_0x4b1ae2={}){this['id']=_0x3908d0,this['video']=_0x151a72,this['_api']=_0x4b1ae2['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']=_0x4b1ae2['maxBu'+'fferD'+'urati'+'on']||0x1e,this['_keep'+'Behin'+'dDura'+'tion']=_0x4b1ae2['keepB'+'ehind'+'Durat'+'ion']||0x5,this['_targ'+'etAhe'+'ad']=_0x4b1ae2['targe'+'tAhea'+'d']!=null?_0x4b1ae2['targe'+'tAhea'+'d']:0.3,this['_maxA'+'head']=_0x4b1ae2['maxAh'+'ead']!=null?_0x4b1ae2['maxAh'+'ead']:0x3;}['on'](_0x519941,_0x29973f){if(!this['_list'+'eners'][_0x519941])this['_list'+'eners'][_0x519941]=new Set();return this['_list'+'eners'][_0x519941]['add'](_0x29973f),this;}['off'](_0x6d28fb,_0x3a8dae){if(this['_list'+'eners'][_0x6d28fb])this['_list'+'eners'][_0x6d28fb]['delet'+'e'](_0x3a8dae);return this;}['_emit'](_0x166b4d,..._0x40441c){const _0x245081=this['_list'+'eners'][_0x166b4d];if(_0x245081){for(const _0x4d9597 of _0x245081)_0x4d9597(..._0x40441c);}}async['start'](_0x59c0d2){if(this['isSta'+'rted'])return;const _0x3ec674=await this['_api']['creat'+'ePlay'+'er'](this['id']);if(!_0x3ec674['succe'+'ss']){this['_emit']('error',new Error(_0x3ec674['error']));return;}this['_unsu'+'bEven'+'t']=this['_api']['onEve'+'nt'](this['id'],_0x48e228=>{this['_hand'+'leEve'+'nt'](_0x48e228['code'],_0x48e228['msg']);}),this['_unsu'+'bInfo']=this['_api']['onInf'+'o'](this['id'],_0x180924=>{this['video'+'Codec'+'Strin'+'g']=_0x180924['video']?_0x180924['video']['codec'+'Strin'+'g']:null,this['audio'+'Codec'+'Strin'+'g']=_0x180924['audio']?_0x180924['audio']['codec'+'Strin'+'g']:null,_0x180924['video']&&_0x180924['video']['width']&&_0x180924['video']['heigh'+'t']&&(this['_vide'+'oWidt'+'h']=_0x180924['video']['width'],this['_vide'+'oHeig'+'ht']=_0x180924['video']['heigh'+'t'],this['_init'+'Canva'+'s']()),this['_init'+'Media'+'Sourc'+'e']();}),this['_unsu'+'bData']=this['_api']['onDat'+'a'](this['id'],_0x3d73f3=>{this['_hand'+'leDat'+'a'](_0x3d73f3);});const _0x46a707=await this['_api']['start'+'Playe'+'r'](this['id'],_0x59c0d2);if(!_0x46a707['succe'+'ss']){this['_emit']('error',new Error(_0x46a707['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(_0x4d468d){}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'](_0x16785f){if(!this['isSta'+'rted'])return {'success':![],'error':'Playe'+'r\x20not'+'\x20star'+'ted'};try{const _0x447959=await this['_api']['start'+'Recor'+'d'](this['id'],_0x16785f);if(_0x447959['succe'+'ss'])this['isRec'+'ordin'+'g']=!![];return _0x447959;}catch(_0x20596f){return {'success':![],'error':_0x20596f['messa'+'ge']};}}async['stopR'+'ecord'](){if(!this['isSta'+'rted'])return {'success':![],'error':'Playe'+'r\x20not'+'\x20star'+'ted'};try{const _0x7bd5a1=await this['_api']['stopR'+'ecord'](this['id']);if(_0x7bd5a1['succe'+'ss'])this['isRec'+'ordin'+'g']=![];return _0x7bd5a1;}catch(_0x26b7a7){return {'success':![],'error':_0x26b7a7['messa'+'ge']};}}['captu'+'reScr'+'eensh'+'ot'](_0x29b75b=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',_0x29b75b);}async['saveS'+'creen'+'shot'](_0x4bdbee,_0x1b3ca2=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 _0x17cf4b=this['_canv'+'as']['toDat'+'aURL']('image'+'/jpeg',_0x1b3ca2),_0x5db71a=_0x17cf4b['subst'+'ring'](_0x17cf4b['index'+'Of'](',')+0x1);return await this['_api']['saveS'+'creen'+'shot'](this['id'],_0x4bdbee,_0x5db71a);}catch(_0x19adb0){return {'success':![],'error':_0x19adb0['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 _0x4646c5=this['audio'+'Codec'+'Strin'+'g']?this['video'+'Codec'+'Strin'+'g']+','+this['audio'+'Codec'+'Strin'+'g']:this['video'+'Codec'+'Strin'+'g'],_0x58cd39='video'+'/mp4;'+'\x20code'+'cs=\x22'+_0x4646c5+'\x22';this['sourc'+'eBuff'+'er']=this['media'+'Sourc'+'e']['addSo'+'urceB'+'uffer'](_0x58cd39),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(_0x3ad324){this['_emit']('error',_0x3ad324);}}),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(_0x489ce9){console['error']('[Vide'+'oPlay'+'er]\x20p'+'roces'+'sQueu'+'e\x20err'+'or:',_0x489ce9),this['_emit']('error',_0x489ce9);}}['_hand'+'leEve'+'nt'](_0x556448,_0x34cd18){this['_emit']('event',_0x556448,_0x34cd18);if(_0x556448===0x3ec)this['_dest'+'royMe'+'diaSo'+'urce']();else {if(_0x556448===0xbb9)this['isRec'+'ordin'+'g']=!![];else (_0x556448===0xbba||_0x556448===0xbbb)&&(this['isRec'+'ordin'+'g']=![]);}}['_hand'+'leDat'+'a'](_0x450dd7){if(!this['isSta'+'rted'])return;try{this['queue']['push'](new Uint8Array(_0x450dd7)['buffe'+'r']),this['isRea'+'dy']&&this['sourc'+'eBuff'+'er']&&!this['sourc'+'eBuff'+'er']['updat'+'ing']&&this['_proc'+'essQu'+'eue']();}catch(_0x4de6c7){console['error']('[Vide'+'oPlay'+'er]\x20h'+'andle'+'Data\x20'+'error'+':',_0x4de6c7),this['_emit']('error',_0x4de6c7);}}['_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(_0x287a4c){}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 _0x57ee69=this['video']&&this['video']['buffe'+'red'];if(!_0x57ee69||_0x57ee69['lengt'+'h']===0x0){console['log']('[Vide'+'oPlay'+'er:'+this['id']+(']\x20buf'+'fer=0'+'.00s,'+'\x20rang'+'es=0'));return;}let _0x57ea98=0x0;for(let _0x5a43be=0x0;_0x5a43be<_0x57ee69['lengt'+'h'];_0x5a43be++)_0x57ea98+=_0x57ee69['end'](_0x5a43be)-_0x57ee69['start'](_0x5a43be);const _0x4a0a72=Math['max'](0x0,_0x57ee69['end'](_0x57ee69['lengt'+'h']-0x1)-this['video']['curre'+'ntTim'+'e']);console['log']('[Vide'+'oPlay'+'er:'+this['id']+(']\x20buf'+'fer=')+_0x57ea98['toFix'+'ed'](0x2)+('s,\x20ra'+'nges=')+_0x57ee69['lengt'+'h']+(',\x20ahe'+'ad=')+_0x4a0a72['toFix'+'ed'](0x2)+'s'),this['_trim'+'Buffe'+'r'](_0x57ea98);if(_0x4a0a72>this['_maxA'+'head'])this['_catc'+'hUp'](_0x4a0a72);},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'](_0x515bec){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 _0x5eb41a=this['video']['buffe'+'red'];if(!_0x5eb41a||_0x5eb41a['lengt'+'h']===0x0)return;const _0x1e361f=typeof _0x515bec==='numbe'+'r'?_0x515bec:((()=>{let _0x2e62a3=0x0;for(let _0x4d2cbd=0x0;_0x4d2cbd<_0x5eb41a['lengt'+'h'];_0x4d2cbd++)_0x2e62a3+=_0x5eb41a['end'](_0x4d2cbd)-_0x5eb41a['start'](_0x4d2cbd);return _0x2e62a3;})());if(_0x1e361f<this['_maxB'+'uffer'+'Durat'+'ion'])return;const _0x293d04=this['video']['curre'+'ntTim'+'e'],_0x29b702=_0x293d04-this['_keep'+'Behin'+'dDura'+'tion'];if(_0x29b702<=0x0)return;const _0xb8df13=_0x5eb41a['start'](0x0);if(_0x29b702<=_0xb8df13)return;try{this['sourc'+'eBuff'+'er']['remov'+'e'](_0xb8df13,_0x29b702),console['log']('[Vide'+'oPlay'+'er:'+this['id']+(']\x20tri'+'m\x20[')+_0xb8df13['toFix'+'ed'](0x2)+',\x20'+_0x29b702['toFix'+'ed'](0x2)+']');}catch(_0x2bcb38){console['error']('[Vide'+'oPlay'+'er:'+this['id']+(']\x20tri'+'mBuff'+'er\x20er'+'ror:'),_0x2bcb38);}}['_catc'+'hUp'](_0x1f280a){if(!this['video']||!this['video']['buffe'+'red'])return;const _0x50c92f=this['video']['buffe'+'red'];if(_0x50c92f['lengt'+'h']===0x0)return;if(this['video']['pause'+'d'])return;const _0x1ee1b2=_0x50c92f['end'](_0x50c92f['lengt'+'h']-0x1),_0x262474=_0x1ee1b2-this['_targ'+'etAhe'+'ad'],_0x19465f=this['video']['curre'+'ntTim'+'e'];if(_0x262474<=_0x19465f)return;if(_0x262474<_0x50c92f['start'](0x0))return;const _0x321487=_0x262474-_0x19465f,_0x505825=typeof _0x1f280a==='numbe'+'r'?'\x20(ahe'+'ad\x20wa'+'s\x20'+_0x1f280a['toFix'+'ed'](0x2)+'s)':'';try{this['video']['curre'+'ntTim'+'e']=_0x262474,console['log']('[Vide'+'oPlay'+'er:'+this['id']+(']\x20cat'+'chUp:'+'\x20seek'+'\x20+')+_0x321487['toFix'+'ed'](0x2)+'s'+_0x505825);}catch(_0xe5fe0a){console['error']('[Vide'+'oPlay'+'er:'+this['id']+(']\x20cat'+'chUp\x20'+'error'+':'),_0xe5fe0a);}}}
|
|
1
|
+
/**
|
|
2
|
+
* VideoPlayer — MediaSource Extension based video player for Electron renderer.
|
|
3
|
+
*
|
|
4
|
+
* Works with the IPC bridge set up by NodePlayer.registerIpc() in the main process
|
|
5
|
+
* and the preload script that exposes window.electronAPI.
|
|
6
|
+
*
|
|
7
|
+
* Usage (ESM in React/Vue):
|
|
8
|
+
* import VideoPlayer from 'nodeplayer-addon/video-player'
|
|
9
|
+
* const player = new VideoPlayer(videoEl, 'my-id')
|
|
10
|
+
* player.on('event', (code, msg) => { ... })
|
|
11
|
+
* player.on('error', (err) => { ... })
|
|
12
|
+
* await player.start('rtsp://...')
|
|
13
|
+
*
|
|
14
|
+
* Usage (UMD in HTML):
|
|
15
|
+
* <script src="video-player.umd.js"></script>
|
|
16
|
+
* <script>
|
|
17
|
+
* const player = new VideoPlayer(videoEl, 'my-id')
|
|
18
|
+
* player.on('event', (code, msg) => { ... })
|
|
19
|
+
* player.on('error', (err) => { ... })
|
|
20
|
+
* player.start('rtsp://...')
|
|
21
|
+
* </script>
|
|
22
|
+
*/
|
|
23
|
+
class VideoPlayer {
|
|
24
|
+
/**
|
|
25
|
+
* @param {HTMLVideoElement} video - The <video> element to render into
|
|
26
|
+
* @param {string} id - Unique player identifier (used for IPC channels)
|
|
27
|
+
* @param {object} [options]
|
|
28
|
+
* @param {object} [options.api] - IPC bridge (default: window.electronAPI)
|
|
29
|
+
* @param {number} [options.maxBufferDuration=15] - 直播缓冲上限(秒),超过则触发清理
|
|
30
|
+
* @param {number} [options.keepBehindDuration=5] - 清理时保留当前播放点之前的秒数
|
|
31
|
+
* @param {number} [options.targetAhead=0.5] - 追赶后的目标 ahead 值(秒)
|
|
32
|
+
* @param {number} [options.maxAhead=3] - ahead 超过此值时触发 seek 追赶
|
|
33
|
+
*/
|
|
34
|
+
constructor(video, id, options = {}) {
|
|
35
|
+
this.id = id;
|
|
36
|
+
this.video = video;
|
|
37
|
+
this._api = options.api || (typeof window !== 'undefined' && window.electronAPI) || null;
|
|
38
|
+
this._listeners = {};
|
|
39
|
+
|
|
40
|
+
this.mediaSource = null;
|
|
41
|
+
this.sourceBuffer = null;
|
|
42
|
+
this.queue = [];
|
|
43
|
+
this.isStarted = false;
|
|
44
|
+
this.isReady = false;
|
|
45
|
+
this.isRecording = false;
|
|
46
|
+
this.videoCodecString = null;
|
|
47
|
+
this.audioCodecString = null;
|
|
48
|
+
|
|
49
|
+
this._videoWidth = 0;
|
|
50
|
+
this._videoHeight = 0;
|
|
51
|
+
this._canvas = null;
|
|
52
|
+
this._canvasCtx = null;
|
|
53
|
+
|
|
54
|
+
this._unsubEvent = null;
|
|
55
|
+
this._unsubInfo = null;
|
|
56
|
+
this._unsubData = null;
|
|
57
|
+
this._bufferStatsTimer = null;
|
|
58
|
+
|
|
59
|
+
// 直播场景的缓冲控制(主动清理已播放数据,避免无限增长)
|
|
60
|
+
// 触发清理的总缓冲阈值
|
|
61
|
+
this._maxBufferDuration = options.maxBufferDuration || 30;
|
|
62
|
+
// 清理时保留当前播放点之前的秒数(抗抖动 + 允许短暂回看)
|
|
63
|
+
this._keepBehindDuration = options.keepBehindDuration || 5;
|
|
64
|
+
|
|
65
|
+
// 直播延迟追赶(任何原因导致 ahead 累积过大时,seek 到接近 buffer 末端)
|
|
66
|
+
// 追赶后的目标 ahead 值
|
|
67
|
+
this._targetAhead = options.targetAhead != null ? options.targetAhead : 0.3;
|
|
68
|
+
// ahead 超过此阈值时触发追赶(截图/卡顿/IPC 慢等场景)
|
|
69
|
+
this._maxAhead = options.maxAhead != null ? options.maxAhead : 3;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 注册事件监听器
|
|
74
|
+
* @param {'event'|'error'} event - 事件名
|
|
75
|
+
* @param {function} fn - 'event': (code: number, msg: string) => void; 'error': (err: Error) => void
|
|
76
|
+
* @returns {this} 支持链式调用
|
|
77
|
+
*/
|
|
78
|
+
on(event, fn) {
|
|
79
|
+
if (!this._listeners[event]) this._listeners[event] = new Set();
|
|
80
|
+
this._listeners[event].add(fn);
|
|
81
|
+
return this
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 移除事件监听器
|
|
86
|
+
* @param {'event'|'error'} event - 事件名
|
|
87
|
+
* @param {function} fn - 要移除的监听函数
|
|
88
|
+
* @returns {this}
|
|
89
|
+
*/
|
|
90
|
+
off(event, fn) {
|
|
91
|
+
if (this._listeners[event]) this._listeners[event].delete(fn);
|
|
92
|
+
return this
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_emit(event, ...args) {
|
|
96
|
+
const set = this._listeners[event];
|
|
97
|
+
if (set) for (const fn of set) fn(...args);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 启动播放
|
|
102
|
+
* @param {string} url - RTSP/RTMP/KMP 地址
|
|
103
|
+
*/
|
|
104
|
+
async start(url) {
|
|
105
|
+
if (this.isStarted) return
|
|
106
|
+
|
|
107
|
+
const createResult = await this._api.createPlayer(this.id);
|
|
108
|
+
if (!createResult.success) {
|
|
109
|
+
this._emit('error', new Error(createResult.error));
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this._unsubEvent = this._api.onEvent(this.id, (data) => {
|
|
114
|
+
this._handleEvent(data.code, data.msg);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
this._unsubInfo = this._api.onInfo(this.id, (info) => {
|
|
118
|
+
this.videoCodecString = info.video ? info.video.codecString : null;
|
|
119
|
+
this.audioCodecString = info.audio ? info.audio.codecString : null;
|
|
120
|
+
if (info.video && info.video.width && info.video.height) {
|
|
121
|
+
this._videoWidth = info.video.width;
|
|
122
|
+
this._videoHeight = info.video.height;
|
|
123
|
+
this._initCanvas();
|
|
124
|
+
}
|
|
125
|
+
this._initMediaSource();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
this._unsubData = this._api.onData(this.id, (data) => {
|
|
129
|
+
this._handleData(data);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const startResult = await this._api.startPlayer(this.id, url);
|
|
133
|
+
if (!startResult.success) {
|
|
134
|
+
this._emit('error', new Error(startResult.error));
|
|
135
|
+
this._cleanupSubscriptions();
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.isStarted = true;
|
|
140
|
+
// this._startBufferStats()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 停止播放并释放资源
|
|
145
|
+
*/
|
|
146
|
+
async stop() {
|
|
147
|
+
if (!this.isStarted) return
|
|
148
|
+
this.isStarted = false;
|
|
149
|
+
this.isReady = false;
|
|
150
|
+
|
|
151
|
+
// this._stopBufferStats()
|
|
152
|
+
this._cleanupSubscriptions();
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
await this._api.stopPlayer(this.id);
|
|
156
|
+
await this._api.destroyPlayer(this.id);
|
|
157
|
+
} catch (e) { /* ignore */ }
|
|
158
|
+
|
|
159
|
+
this._destroyMediaSource();
|
|
160
|
+
this._canvas = null;
|
|
161
|
+
this._canvasCtx = null;
|
|
162
|
+
this._videoWidth = 0;
|
|
163
|
+
this._videoHeight = 0;
|
|
164
|
+
this.queue = [];
|
|
165
|
+
this.isRecording = false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 开始录像
|
|
170
|
+
* @param {string} [outputPath] - 输出文件路径(可选,由主进程自动生成)
|
|
171
|
+
* @returns {Promise<{success: boolean, path?: string, error?: string}>}
|
|
172
|
+
*/
|
|
173
|
+
async startRecord(outputPath) {
|
|
174
|
+
if (!this.isStarted) return { success: false, error: 'Player not started' }
|
|
175
|
+
try {
|
|
176
|
+
const result = await this._api.startRecord(this.id, outputPath);
|
|
177
|
+
if (result.success) this.isRecording = true;
|
|
178
|
+
return result
|
|
179
|
+
} catch (e) {
|
|
180
|
+
return { success: false, error: e.message }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 停止录像
|
|
186
|
+
* @returns {Promise<{success: boolean, error?: string}>}
|
|
187
|
+
*/
|
|
188
|
+
async stopRecord() {
|
|
189
|
+
if (!this.isStarted) return { success: false, error: 'Player not started' }
|
|
190
|
+
try {
|
|
191
|
+
const result = await this._api.stopRecord(this.id);
|
|
192
|
+
if (result.success) this.isRecording = false;
|
|
193
|
+
return result
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return { success: false, error: e.message }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 截取当前视频帧,返回 JPG 格式的 data URL
|
|
201
|
+
* @param {number} [quality=0.9] - JPG 质量 (0-1)
|
|
202
|
+
* @returns {string|null} data URL (image/jpeg),未就绪时返回 null
|
|
203
|
+
*/
|
|
204
|
+
captureScreenshot(quality = 0.9) {
|
|
205
|
+
if (!this.isReady || !this._canvas || !this.video) return null
|
|
206
|
+
this._canvasCtx.drawImage(this.video, 0, 0, this._videoWidth, this._videoHeight);
|
|
207
|
+
return this._canvas.toDataURL('image/jpeg', quality)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 截取当前视频帧并通过 IPC 保存到指定路径(JPG 格式)
|
|
212
|
+
* @param {string} [outputPath] - 保存路径(可选,默认自动生成)
|
|
213
|
+
* @param {number} [quality=0.9] - JPG 质量 (0-1)
|
|
214
|
+
* @returns {Promise<{success: boolean, path?: string, error?: string}>}
|
|
215
|
+
*/
|
|
216
|
+
async saveScreenshot(outputPath, quality = 0.9) {
|
|
217
|
+
if (!this.isReady || !this._canvas || !this.video) {
|
|
218
|
+
return { success: false, error: 'Stream not ready' }
|
|
219
|
+
}
|
|
220
|
+
if (!this._api || !this._api.saveScreenshot) {
|
|
221
|
+
return { success: false, error: 'IPC saveScreenshot not available' }
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
this._canvasCtx.drawImage(this.video, 0, 0, this._videoWidth, this._videoHeight);
|
|
225
|
+
const dataUrl = this._canvas.toDataURL('image/jpeg', quality);
|
|
226
|
+
const base64 = dataUrl.substring(dataUrl.indexOf(',') + 1);
|
|
227
|
+
return await this._api.saveScreenshot(this.id, outputPath, base64)
|
|
228
|
+
} catch (e) {
|
|
229
|
+
return { success: false, error: e.message }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ============ Internal ============
|
|
234
|
+
|
|
235
|
+
_initCanvas() {
|
|
236
|
+
this._canvas = document.createElement('canvas');
|
|
237
|
+
this._canvas.width = this._videoWidth;
|
|
238
|
+
this._canvas.height = this._videoHeight;
|
|
239
|
+
this._canvasCtx = this._canvas.getContext('2d');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_initMediaSource() {
|
|
243
|
+
if (!this.videoCodecString || !this.video) return
|
|
244
|
+
|
|
245
|
+
this.mediaSource = new MediaSource();
|
|
246
|
+
this.video.src = URL.createObjectURL(this.mediaSource);
|
|
247
|
+
|
|
248
|
+
this.mediaSource.addEventListener('sourceopen', () => {
|
|
249
|
+
if (this.mediaSource.readyState !== 'open') return
|
|
250
|
+
try {
|
|
251
|
+
const codecs = this.audioCodecString
|
|
252
|
+
? this.videoCodecString + ',' + this.audioCodecString
|
|
253
|
+
: this.videoCodecString;
|
|
254
|
+
const mimeType = 'video/mp4; codecs="' + codecs + '"';
|
|
255
|
+
this.sourceBuffer = this.mediaSource.addSourceBuffer(mimeType);
|
|
256
|
+
this.sourceBuffer.addEventListener('updateend', () => this._processQueue());
|
|
257
|
+
this.isReady = true;
|
|
258
|
+
if (this.queue.length > 0) this._processQueue();
|
|
259
|
+
} catch (e) {
|
|
260
|
+
this._emit('error', e);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
this.mediaSource.addEventListener('sourceclose', () => {
|
|
265
|
+
this.isReady = false;
|
|
266
|
+
this.sourceBuffer = null;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
_processQueue() {
|
|
271
|
+
if (!this.isReady || !this.sourceBuffer) return
|
|
272
|
+
if (this.sourceBuffer.updating) return
|
|
273
|
+
if (this.queue.length === 0) return
|
|
274
|
+
try {
|
|
275
|
+
if (this.mediaSource && this.mediaSource.readyState === 'open') {
|
|
276
|
+
this.sourceBuffer.appendBuffer(this.queue.shift());
|
|
277
|
+
}
|
|
278
|
+
} catch (e) {
|
|
279
|
+
console.error('[VideoPlayer] processQueue error:', e);
|
|
280
|
+
this._emit('error', e);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
_handleEvent(code, msg) {
|
|
285
|
+
this._emit('event', code, msg);
|
|
286
|
+
|
|
287
|
+
if (code === 1004) {
|
|
288
|
+
this._destroyMediaSource();
|
|
289
|
+
} else if (code === 3001) {
|
|
290
|
+
this.isRecording = true;
|
|
291
|
+
} else if (code === 3002 || code === 3003) {
|
|
292
|
+
this.isRecording = false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
_handleData(data) {
|
|
297
|
+
if (!this.isStarted) return
|
|
298
|
+
try {
|
|
299
|
+
this.queue.push(new Uint8Array(data).buffer);
|
|
300
|
+
if (this.isReady && this.sourceBuffer && !this.sourceBuffer.updating) {
|
|
301
|
+
this._processQueue();
|
|
302
|
+
}
|
|
303
|
+
} catch (e) {
|
|
304
|
+
console.error('[VideoPlayer] handleData error:', e);
|
|
305
|
+
this._emit('error', e);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
_destroyMediaSource() {
|
|
310
|
+
if (this.sourceBuffer && this.mediaSource && this.mediaSource.readyState === 'open') {
|
|
311
|
+
try {
|
|
312
|
+
this.sourceBuffer.abort();
|
|
313
|
+
this.mediaSource.removeSourceBuffer(this.sourceBuffer);
|
|
314
|
+
} catch (e) { /* ignore */ }
|
|
315
|
+
}
|
|
316
|
+
if (this.video && this.video.src && this.video.src.startsWith('blob:')) {
|
|
317
|
+
URL.revokeObjectURL(this.video.src);
|
|
318
|
+
}
|
|
319
|
+
if (this.video) {
|
|
320
|
+
this.video.removeAttribute('src');
|
|
321
|
+
this.video.load();
|
|
322
|
+
}
|
|
323
|
+
this.sourceBuffer = null;
|
|
324
|
+
this.mediaSource = null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
_cleanupSubscriptions() {
|
|
328
|
+
if (this._unsubEvent) { this._unsubEvent(); this._unsubEvent = null; }
|
|
329
|
+
if (this._unsubInfo) { this._unsubInfo(); this._unsubInfo = null; }
|
|
330
|
+
if (this._unsubData) { this._unsubData(); this._unsubData = null; }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 启动每秒打印 video buffer 状态的循环
|
|
335
|
+
* 输出: 总缓冲秒数、缓冲分段数、领先当前播放点的秒数
|
|
336
|
+
*/
|
|
337
|
+
_startBufferStats() {
|
|
338
|
+
this._stopBufferStats();
|
|
339
|
+
this._bufferStatsTimer = setInterval(() => {
|
|
340
|
+
const b = this.video && this.video.buffered;
|
|
341
|
+
if (!b || b.length === 0) {
|
|
342
|
+
console.log(`[VideoPlayer:${this.id}] buffer=0.00s, ranges=0`);
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
let total = 0;
|
|
346
|
+
for (let i = 0; i < b.length; i++) total += b.end(i) - b.start(i);
|
|
347
|
+
const ahead = Math.max(0, b.end(b.length - 1) - this.video.currentTime);
|
|
348
|
+
console.log(`[VideoPlayer:${this.id}] buffer=${total.toFixed(2)}s, ranges=${b.length}, ahead=${ahead.toFixed(2)}s`);
|
|
349
|
+
this._trimBuffer(total);
|
|
350
|
+
if (ahead > this._maxAhead) this._catchUp(ahead);
|
|
351
|
+
}, 1000);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
_stopBufferStats() {
|
|
355
|
+
if (this._bufferStatsTimer) {
|
|
356
|
+
clearInterval(this._bufferStatsTimer);
|
|
357
|
+
this._bufferStatsTimer = null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* 清理已播放过的缓冲数据(直播场景)
|
|
363
|
+
* 策略:总缓冲超过 _maxBufferDuration 时,remove 掉 [bufferedStart, currentTime - _keepBehindDuration)
|
|
364
|
+
* @param {number} [totalDuration] - 预计算的总缓冲时长(秒),省略时内部重新计算
|
|
365
|
+
*/
|
|
366
|
+
_trimBuffer(totalDuration) {
|
|
367
|
+
if (!this.sourceBuffer || !this.mediaSource || this.mediaSource.readyState !== 'open') return
|
|
368
|
+
// SourceBuffer 正在 append/remove 时不能再次操作
|
|
369
|
+
if (this.sourceBuffer.updating) return
|
|
370
|
+
|
|
371
|
+
const b = this.video.buffered;
|
|
372
|
+
if (!b || b.length === 0) return
|
|
373
|
+
|
|
374
|
+
const total = (typeof totalDuration === 'number')
|
|
375
|
+
? totalDuration
|
|
376
|
+
: (() => { let s = 0; for (let i = 0; i < b.length; i++) s += b.end(i) - b.start(i); return s })();
|
|
377
|
+
|
|
378
|
+
// 未超阈值,不清理(避免每秒 remove 造成碎片)
|
|
379
|
+
if (total < this._maxBufferDuration) return
|
|
380
|
+
|
|
381
|
+
const currentTime = this.video.currentTime;
|
|
382
|
+
const removeEnd = currentTime - this._keepBehindDuration;
|
|
383
|
+
// 当前播放点还太靠前,保留区还未形成
|
|
384
|
+
if (removeEnd <= 0) return
|
|
385
|
+
|
|
386
|
+
const trimStart = b.start(0);
|
|
387
|
+
// removeEnd 必须严格大于 trimStart 才有意义
|
|
388
|
+
if (removeEnd <= trimStart) return
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
this.sourceBuffer.remove(trimStart, removeEnd);
|
|
392
|
+
console.log(`[VideoPlayer:${this.id}] trim [${trimStart.toFixed(2)}, ${removeEnd.toFixed(2)}]`);
|
|
393
|
+
} catch (e) {
|
|
394
|
+
console.error(`[VideoPlayer:${this.id}] trimBuffer error:`, e);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* 直播延迟追赶:seek 到 bufferedEnd - targetAhead
|
|
400
|
+
* 触发场景:截图、主线程阻塞、IPC 慢、网络突发等导致 ahead 累积
|
|
401
|
+
* @param {number} [currentAhead] - 预计算的 ahead 值,仅用于日志
|
|
402
|
+
*/
|
|
403
|
+
_catchUp(currentAhead) {
|
|
404
|
+
if (!this.video || !this.video.buffered) return
|
|
405
|
+
const b = this.video.buffered;
|
|
406
|
+
if (b.length === 0) return
|
|
407
|
+
|
|
408
|
+
// 如果 video 被暂停(用户主动暂停),不追赶 —— 否则会强行拉回播放
|
|
409
|
+
if (this.video.paused) return
|
|
410
|
+
|
|
411
|
+
const bufferedEnd = b.end(b.length - 1);
|
|
412
|
+
const targetTime = bufferedEnd - this._targetAhead;
|
|
413
|
+
const currentTime = this.video.currentTime;
|
|
414
|
+
|
|
415
|
+
// target 必须严格大于 current 才有意义
|
|
416
|
+
if (targetTime <= currentTime) return
|
|
417
|
+
// 确保目标点在已缓冲范围内(seek 安全)
|
|
418
|
+
if (targetTime < b.start(0)) return
|
|
419
|
+
|
|
420
|
+
const jump = targetTime - currentTime;
|
|
421
|
+
const aheadLabel = (typeof currentAhead === 'number') ? ` (ahead was ${currentAhead.toFixed(2)}s)` : '';
|
|
422
|
+
try {
|
|
423
|
+
this.video.currentTime = targetTime;
|
|
424
|
+
console.log(`[VideoPlayer:${this.id}] catchUp: seek +${jump.toFixed(2)}s${aheadLabel}`);
|
|
425
|
+
} catch (e) {
|
|
426
|
+
console.error(`[VideoPlayer:${this.id}] catchUp error:`, e);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
2
430
|
|
|
3
431
|
export { VideoPlayer as default };
|