Greasy Fork is available in English.

HTML5视频截图器

基于HTML5的简单原生视频截图,可控制快进/逐帧/视频调速,支持自定义快捷键

  1. // ==UserScript==
  2. // @name HTML5视频截图器
  3. // @namespace indefined
  4. // @supportURL https://github.com/indefined/UserScripts/issues
  5. // @version 0.4.18
  6. // @description 基于HTML5的简单原生视频截图,可控制快进/逐帧/视频调速,支持自定义快捷键
  7. // @author indefined
  8. // @include *://*
  9. // @run-at document-end
  10. // @grant GM_registerMenuCommand
  11. // @grant GM_getValue
  12. // @grant GM_setValue
  13. // @grant GM.getValue
  14. // @grant GM.setValue
  15. // @require https://cdn.staticfile.org/jszip/3.1.5/jszip.min.js
  16. // @license MIT
  17. // ==/UserScript==
  18.  
  19. (async function HTML5VideoCapturer(){
  20. 'use strict';
  21. let allConfigs,config;
  22. if(typeof(GM)!='undefined') {
  23. GM_getValue = GM.getValue;
  24. GM_setValue = GM.setValue;
  25. }
  26. //设置保存读取相关配置和逻辑
  27. const configList = {
  28. active:{
  29. content:'开启/关闭工具栏',
  30. title:'全局工具栏快捷键开关,必须至少同时按下ctrl/shift/alt之一,尽量避免常用快捷键以免冲突',
  31. key:'A',
  32. ctrlKey:true,
  33. shiftKey:false,
  34. altKey:true
  35. },
  36. capture:{
  37. content:'截图',
  38. title:'按下该按键打开新窗口显示截图,同时按住shift会尝试直接下载,如果直接下载失败也会打开新窗口',
  39. key:'s'
  40. },
  41. downCapture:{
  42. content:'直接下载截图',
  43. title:'按下该按键会尝试直接下载,如果直接下载失败效果等同于按下截图',
  44. key:'q'
  45. },
  46. preFrame:{
  47. content:'上一帧',
  48. title:'使视频后退一帧(最小分辨率1/60秒)',
  49. key:'d'
  50. },
  51. nextFrame:{
  52. content:'下一帧',
  53. title:'使视频前进一帧(最小分辨率1/60秒)',
  54. key:'f'
  55. },
  56. backward:{
  57. content:'后退',
  58. title:'使视频后退1秒,按住ctrl/shift/alt快退倍率等同工具栏按钮说明',
  59. key:'ArrowLeft'
  60. },
  61. forward:{
  62. content:'前进',
  63. title:'使视频前进1秒,按住ctrl/shift/alt快进倍率等同工具栏按钮说明',
  64. key:'ArrowRight'
  65. },
  66. play:{
  67. content:'播放/暂停',
  68. title:'切换视频播放/暂停状态,由于大部分视频自带空格播放暂停功能,不建议全局设置为空格以免冲突',
  69. key:''
  70. },
  71. speedOri:{
  72. content:'原速',
  73. title:'恢复1倍速播放视频',
  74. key:'z',
  75. },
  76. speedDown:{
  77. content:'减速',
  78. title:'使视频减速,小于1倍速时步长为0.1倍速,最小有效值为0.1倍',
  79. key:'x'
  80. },
  81. speedUp:{
  82. content:'加速',
  83. title:'使视频加速,大于1倍速时步长0.25倍速,最大有效值大概为16倍',
  84. key:'c'
  85. },
  86. panelActive:{
  87. content:'快捷键在截图工具栏上有效',
  88. title:'当鼠标光标在工具栏上非输入区域时快捷键会生效,快捷键作用与点击操作按钮相同会操作选中视频',
  89. type:'checkbox',
  90. key:'',
  91. checked:true,
  92. disabled:true
  93. },
  94. playerActive:{
  95. content:'启用播放器悬停快捷键支持',
  96. title:'勾选时当鼠标悬停在视频上时快捷键会生效,无论工具栏是否打开,快捷键会直接操作被悬停视频且不会有提示。'
  97. + '\n由于各种播放器外壳影响该功能可能不会生效,且可能和播放器外壳自身快捷键冲突,建议针对网站设置是否开启',
  98. type:'checkbox',
  99. key:'',
  100. checked:false
  101. },
  102. stopPropagation:{
  103. content:'尝试覆盖网页快捷键',
  104. title:'勾选此选项则触发快捷键时会尝试忽略覆盖网页原有快捷键,不一定会生效',
  105. type:'checkbox',
  106. key:'',
  107. checked:false
  108. },
  109. saveAsPNG:{
  110. content:'直接下载截图保存为png格式',
  111. title:'勾选此项则下载的截图为原图png格式,默认保存为jpg(体积较小)',
  112. type:'checkbox',
  113. key:'',
  114. checked:false
  115. },
  116. saveAsTimeStamp:{
  117. content:'截图文件名按照当前时间保存',
  118. title:'勾选此项则下载的截图文件名按照当前时间戳保存,否则按照视频播放时间保存',
  119. type:'checkbox',
  120. key:'',
  121. checked:false
  122. },
  123. forceStepStop:{
  124. content:'逐帧强制暂停',
  125. title:'勾选此项后进行逐帧操作将临时挟持忽略视频的播放功能从而实现强制暂停,适用性和副作用未知,建议按需开启',
  126. type:'checkbox',
  127. key:'',
  128. checked:false
  129. },
  130. crossOrigin:{
  131. content:'视频匿名跨域',
  132. title:'如果此选项被勾选,则网页打开时加载的非本网站视频会匿名访问。\n'
  133. +'此操作可以解决部分视频无法直接下载截图问题,但可能导致更多视频无法播放,建议仅在无法截图网站尝试设置。\n'
  134. +'注意此功能是一次性静态执行的,只会在网站刚加载时运行一次,后续加载的视频无效,且更改后需刷新生效。',
  135. type:'checkbox',
  136. key:'',
  137. checked:false
  138. }
  139. };
  140. /**
  141. 深拷贝一个配置,并检查缺失配置项和无效项
  142. value: 待检查/拷贝的配置,可为空。
  143. return: 深拷贝的配置对象,去除无效项并添加缺失项默认值。当value为空时返回默认配置的克隆
  144. **/
  145. function cloneConfig(value) {
  146. const clone = {};
  147. if(!value) value = configList;
  148. for(const item in configList) {
  149. //使用assign覆盖合并拷贝……因为二层结构暂时没有引用变量大概安全吧……
  150. clone[item] = Object.assign({},configList[item],value[item]);
  151. /*
  152. for(const i in configList[item]) {
  153. if(value[item]) clone[item][i] = value[item][i];
  154. else clone[item][i] = configList[item][i];
  155. }
  156. */
  157. }
  158. for (const i in clone) {
  159. clone[i].key = clone[i].key.toUpperCase();
  160. }
  161. return clone;
  162. }
  163. //读取全部设置,赋值本网站生效配置的全局变量,并返回指定域名的单独配置
  164. async function getConfig(site){
  165. try{
  166. allConfigs = await GM_getValue('config','{}');
  167. if(allConfigs) allConfigs = JSON.parse(allConfigs);
  168. }catch(e){
  169. toast('读取配置错误,将使用默认配置',e);
  170. allConfigs = {};
  171. GM_setValue('config',JSON.stringify(allConfigs));
  172. }
  173. //使用深拷贝方法查缺和去无效
  174. config = cloneConfig(allConfigs[document.location.host] || allConfigs.default);
  175. //如果没有开启全局播放器快捷键则关闭悬停监听,这函数挺神经病的
  176. document.removeEventListener('mousemove',hoverListener);
  177. if(config.playerActive.checked) document.addEventListener('mousemove',hoverListener);
  178. //有要求读取的网站配置时,返回要求的配置,否则返回默认配置,否则(全空,初次运行)返回初始配置
  179. return allConfigs[site]||allConfigs.default||config;
  180. }
  181. /**
  182. 保存某个网站设置
  183. value: 待保存的网站配置值,如为空则会删除该网站设置
  184. site: 待保存的网站
  185. **/
  186. async function saveConfig(value,site) {
  187. if(!value) {
  188. if(site&&site!='default') delete allConfigs[site];
  189. else {
  190. allConfigs.default = cloneConfig();
  191. }
  192. }
  193. else {
  194. allConfigs[site||'default'] = value;
  195. }
  196. //删除没必要保存的额外成员
  197. Object.values(allConfigs).forEach(config=>{
  198. Object.values(config).forEach(item=>{
  199. delete item.title;
  200. delete item.content;
  201. });
  202. });
  203. await GM_setValue('config',JSON.stringify(allConfigs));
  204. //为了防止保存延迟,延时重新加载设置
  205. setTimeout(()=>{
  206. getConfig(document.location.host);
  207. //通知iframe重新加载设置
  208. [].forEach.call(childs,(w,i)=>w.postMessage({action:'reload'},'*'));
  209. },100);
  210. }
  211.  
  212. //读取加载配置
  213. await getConfig();
  214.  
  215. //匿名跨域,仅在启动时检查并操作一遍,实际作用有限
  216. if (config.crossOrigin.checked) {
  217. Array.from(document.querySelectorAll('video')).forEach(v=>{
  218. if(!v.src||v.src.indexOf(location.host)==-1) {
  219. v.setAttribute('crossorigin','anonymous');
  220. }
  221. });
  222. }
  223.  
  224. //截图和视频操作相关函数从以下开始
  225. const childs = "undefined"==typeof(unsafeWindow)?window.frames:unsafeWindow.frames;
  226. let videos, video, selectId, hoverItem;
  227.  
  228. //监听鼠标是否悬停在视频或工具栏……极其低效却很简单通用的实现,开启关闭判断放在getConfig中
  229. //不需要监听视频悬停时应当移除(工具栏自带悬停检测,但作用会被该函数覆盖)
  230. function hoverListener(ev) {
  231. if (ev.target instanceof HTMLVideoElement || (window==top&&panel&&panel.contains(ev.target))) hoverItem = ev.target;
  232. else hoverItem = undefined;
  233. }
  234.  
  235. function videoCapture(download){
  236. if (!video) return;
  237. const canvas = document.createElement("canvas");
  238. canvas.width = video.videoWidth;
  239. canvas.height = video.videoHeight;
  240. canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
  241. canvas.dataset.timestamp = config.saveAsTimeStamp.checked
  242. ? new Date().toLocaleString('zh', {hour12: false})
  243. :`${Math.floor(video.currentTime/60)}_${(video.currentTime%60).toFixed(3)}`;
  244. if (download) downloadCapture(canvas);
  245. else appendToCanvasList(canvas);
  246. }
  247.  
  248. function getCaptureName(canvas) {
  249. const type = config.saveAsPNG.checked ? 'png' : 'jpg',
  250. timestamp = canvas.dataset.timestamp;
  251. return `${document.title}_${timestamp}.${type}`
  252. }
  253.  
  254. function downloadCapture(canvas){
  255. if (canvas.dataset.dirty) return;
  256. canvas2Blob(canvas).then(blob=>{
  257. saveAs(blob, getCaptureName(canvas))
  258. }).catch(e=>{
  259. appendToCanvasList(canvas);
  260. });
  261. }
  262.  
  263. function canvas2Blob(canvas){
  264. if (canvas.dataset.dirty) return Promise.reject('由于视频跨域安全限制此截图无法转换');
  265. return new Promise(resolve=>{
  266. const type = config.saveAsPNG.checked ? 'image/png' : 'image/jpeg';
  267. canvas.toBlob(resolve, type, 0.98); //0.98质量参数仅在jpg时有效,png时自动被忽略
  268. }).catch(e=>{
  269. canvas.dataset.dirty = true;
  270. throw e;
  271. })
  272. }
  273.  
  274. function saveAs(blob, name){
  275. const a = document.createElement('a');
  276. a.href = URL.createObjectURL(blob);
  277. a.download = name;
  278. document.head.appendChild(a);
  279. a.click();
  280. document.head.removeChild(a);
  281. }
  282.  
  283. function downloadAll() {
  284. if (!canvasList) return;
  285. const list = Array.from(canvasList.querySelectorAll('canvas:not([data-dirty])'));
  286. if (!list.length) return;
  287. Promise.all(list.map(canvas2Blob)).then(blobs=>{
  288. const zip = new JSZip();
  289. blobs.forEach((blob, idx)=>zip.file(getCaptureName(list[idx]), blob));
  290. return zip.generateAsync({type: "blob"}).then(blob=>saveAs(blob, `${document.title}_截图_.zip`));
  291. }).catch(e=>console.error(e));
  292. }
  293.  
  294. // 暂存截图画布的悬浮列表
  295. let canvasContainer, canvasList, downloadBtn;
  296. function createCanvasList() {
  297. canvasContainer = _c({
  298. nodeType: 'div',
  299. className: 'h5vc-canvas-container',
  300. childs:[
  301. {
  302. nodeType: 'style',
  303. innerHTML: '.h5vc-canvas-container{position: fixed; right: 0; top: 0; z-index: 2000000;}'
  304. + '.h5vc-canvas-list{max-height: calc(100vh - 50px); overflow: auto; background: black; }'
  305. + '.h5vc-list-item{position: relative; width: 240px; height: 135px; margin: 5px;}'
  306. + '.h5vc-list-item canvas{max-width: 240px; max-height: 135px;}'
  307. + '.h5vc-list-item canvas[data-dirty]+.h5vc-item-download:before{content: "无法"}'
  308. + '.h5vc-list-item canvas[data-dirty]+.h5vc-item-download:after{content: ",请右键另存"}'
  309. + '.h5vc-list-item .h5vc-item-download{right: 5px;bottom: 5px;font-size: 14px;font-weight: 500;cursor: pointer;}'
  310. + '.h5vc-list-item .h5vc-item-remove{top: 5px;right: 5px;font-size: 16px;font-weight: 500;cursor: pointer;}'
  311. + '.h5vc-list-item .h5vc-item-new-tab{top: 5px;left: 5px;font-size: 14px;font-weight: 500;cursor: pointer;}'
  312. + '.h5vc-list-item .h5vc-item-time{left: 5px;font-size: 14px;bottom: 5px;}'
  313. + '.h5vc-list-item span{position: absolute;color:#FFF;text-shadow: #000 1px 1px 2px}'
  314. + '.h5vc-canvas-batch{margin-top: 5px}'
  315. + '.h5vc-canvas-batch button{height: 35px;width: 80px;background: #000c;color: #fff;border: none;margin: 0 20px;border-radius: 7px;}'
  316. },
  317. canvasList = _c({
  318. nodeType: 'div',
  319. className: 'h5vc-canvas-list',
  320. })
  321. ],
  322. parent: document.body
  323. });
  324. downloadBtn = _c({
  325. nodeType: 'div', className: 'h5vc-canvas-batch',
  326. childs: [
  327. {
  328. nodeType: 'button', innerText: '下载全部',
  329. onclick: downloadAll
  330. },
  331. {
  332. nodeType: 'button', innerText: '删除全部',
  333. onclick: clearList
  334. }
  335. ]
  336. });
  337. }
  338.  
  339. function appendToNewTab(canvas) {
  340. const tab = open('', '_blank');
  341. tab.document.body.appendChild(canvas);
  342. _c({
  343. nodeType: 'style', innerHTML: 'canvas:{max-width: 100%}span{display: flex;}.h5vc-item-remove,.h5vc-item-new-tab{display: none}'
  344. + '.h5vc-list-item canvas[data-dirty]+.h5vc-item-download:before{content: "无法"}'
  345. + '.h5vc-list-item canvas[data-dirty]+.h5vc-item-download:after{content: ",请右键另存"}',
  346. parent: tab.document.body
  347. });
  348. checkCanvasList();
  349. }
  350.  
  351. function appendToCanvasList(canvas) {
  352. if (!canvasList) createCanvasList();
  353. if (canvasList.contains(canvas)) return;
  354. canvasList.appendChild(_c({
  355. nodeType: 'div',
  356. className: 'h5vc-list-item',
  357. childs: [
  358. canvas,
  359. {
  360. nodeType: 'span', className: 'h5vc-item-download',
  361. innerText: '下载',
  362. onclick: ()=> downloadCapture(canvas)
  363. },
  364. {
  365. nodeType: 'span', className: 'h5vc-item-new-tab',
  366. innerText: '新窗口',
  367. onclick: function(){appendToNewTab(this.parentNode)}
  368. },
  369. {
  370. nodeType: 'span', className: 'h5vc-item-remove',
  371. innerText: 'X',
  372. onclick: function(){removeFromList(this.parentNode)}
  373. },
  374. {
  375. nodeType: 'span', className: 'h5vc-item-time',
  376. innerText: canvas.dataset.timestamp.replace(/_/g, ':')
  377. }
  378. ]
  379. }));
  380. checkCanvasList();
  381. }
  382.  
  383. function removeFromList(canvas) {
  384. if (!canvasList || !canvas.contains(canvas)) return;
  385. canvasList.removeChild(canvas);
  386. checkCanvasList();
  387. }
  388.  
  389. function clearList() {
  390. if (!canvasList) return;
  391. canvasList.innerText = '';
  392. checkCanvasList();
  393. }
  394.  
  395. function checkCanvasList() {
  396. if (!canvasContainer) return;
  397. if (canvasList.childElementCount == 0 && canvasContainer.contains(downloadBtn)) {
  398. canvasContainer.removeChild(downloadBtn);
  399. }
  400. else if (canvasList.childElementCount > 1 && !canvasContainer.contains(downloadBtn)) {
  401. canvasContainer.appendChild(downloadBtn);
  402. }
  403. }
  404.  
  405. // 以下为视频控制部分
  406. function videoPlay(){
  407. if (!video) return;
  408. video.paused?video.play():video.pause();
  409. }
  410.  
  411. function videoSpeedChange(speed){
  412. if (!video) return;
  413. video.playbackRate = speed;
  414. }
  415.  
  416. function nothing(){}
  417.  
  418. function videoStep(offset){
  419. if (!video) return;
  420. if (Math.abs(offset)<1) {
  421. if (config.forceStepStop.checked) {
  422. if (video.play!=nothing) {
  423. video.doPlayBackup = video.play;
  424. video.play = nothing;
  425. }
  426. clearTimeout(video.restorePlayTimmer);
  427. video.restorePlayTimmer = setTimeout((function(){
  428. this.play = this.doPlayBackup;
  429. }).bind(video),150);
  430. }
  431. if (!video.paused) video.pause();
  432. }
  433. video.currentTime += offset;
  434. if(video.currentTime<0) video.currentTime = 0;
  435. }
  436.  
  437. function isInView(v) {
  438. if (!v) return false;
  439. var vh = document.documentElement.clientHeight,
  440. vw = document.documentElement.clientWidth,
  441. br = v.getBoundingClientRect(),
  442. h = br.height,
  443. w = br.width,
  444. vt = br.top,
  445. vl = br.left;
  446. return (h>0&&w>0&&vt>=0&&vt<vh&&vl>=0&&vl<vw);
  447. }
  448.  
  449. function videoDetech(){
  450. videos = Array.from(document.querySelectorAll('video'));
  451. if (window!=top){
  452. top.postMessage({
  453. action:'captureReport',
  454. about:'videoNums',
  455. length:videos.length,
  456. host:location.host,
  457. id:window.captureId
  458. },'*');
  459. }else{
  460. while(selector.firstChild) selector.removeChild(selector.firstChild);
  461. appendVideo(videos);
  462. setTimeout(()=>{
  463. if (selector.childNodes.length) {
  464. //优先在顶层窗体找一个之前选中的视频或者正在播放的视频或者在视图中的视频,iframe里就不管了……
  465. var value = videos.findIndex(v=>v==video);
  466. if(value<0) value = videos.findIndex(v=>!v.paused);
  467. if(value<0) value = videos.findIndex(isInView);
  468. if(value<0) value = selector.value;
  469. return videoSelect(value);
  470. }
  471. toast('当前页面没有检测到HTML5视频');
  472. },100);
  473. }
  474. if (childs.length){
  475. [].forEach.call(childs,(w,i)=>w.postMessage({
  476. action:'captureDetech',
  477. id:window.captureId==undefined?i:window.captureId+'-'+i
  478. },'*'));
  479. }
  480. console.log(window.captureId,videos);
  481. }
  482.  
  483. function videoSelect(id){
  484. selectId = id;
  485. if(video) {
  486. video.removeEventListener('play',videoStatusUpdate);
  487. video.removeEventListener('pause',videoStatusUpdate);
  488. video.removeEventListener('ratechange',videoStatusUpdate);
  489. }
  490. if (videos[id]){
  491. video = videos[id];
  492. video.addEventListener('play',videoStatusUpdate);
  493. video.addEventListener('pause',videoStatusUpdate);
  494. video.addEventListener('ratechange',videoStatusUpdate);
  495. video.scrollIntoView();
  496. videoStatusUpdate();
  497. }
  498. else {
  499. video = undefined;
  500. postMsg('select');
  501. }
  502. }
  503.  
  504. function videoStatusUpdate(){
  505. if (window==top) {
  506. play.innerText = video.paused?"播放":"暂停";
  507. speed.value = video.playbackRate;
  508. }
  509. else{
  510. top.postMessage({
  511. action:'captureReport',
  512. about:'videoStatus',
  513. paused:video.paused,
  514. speed:video.playbackRate,
  515. id:window.captureId
  516. },'*');
  517. }
  518. }
  519.  
  520. //向包含目标视频的子窗体发送控制信息,全局变量selectId逐层往下层递归
  521. function postMsg(type,data){
  522. if (selectId==undefined||selectId=='') return;
  523. const ids = selectId.split('-');
  524. if (ids.length>1){
  525. const target = ids.shift();
  526. if (!childs[target]) return;
  527. childs[target].postMessage({
  528. action:'captureControl',
  529. target:window.captureId==undefined?target:window.captureId+'-'+target,
  530. todo:type,
  531. id:ids.join('-'),
  532. value:data
  533. },'*');
  534. }
  535. }
  536.  
  537. //视频动作处理函数,接收视频控制数据并转发调用最终处理
  538. function videoAction(todo, value, id) {
  539. if (todo=='select'&&id) videoSelect(id);
  540. else if (video) {
  541. switch (todo){
  542. case 'play':
  543. videoPlay(value);
  544. break;
  545. case 'shot':
  546. videoCapture(value);
  547. break;
  548. case 'step':
  549. videoStep(value);
  550. break;
  551. case 'speed':
  552. videoSpeedChange(value);
  553. break;
  554. default:
  555. break;
  556. }
  557. }
  558. else {
  559. postMsg(todo, value);
  560. }
  561. }
  562.  
  563. //命令处理函数,分析命令,处理命令参数并调用视频控制
  564. //action: 动作命令,与快捷键存储变量名相同
  565. //ev: 调用命令时的按键动作,部分命令对shift/ctrl/alt敏感
  566. function actionHandler(action, ev) {
  567. //console.log(action, ev)
  568. let value;
  569. switch(action) {
  570. case 'speedUp':
  571. if(video) value = video.playbackRate+(video.playbackRate<1?0.1:0.25);
  572. else if(speed) {
  573. speed.step = speed.value<1?0.1:0.25;
  574. value = +speed.value + (+speed.step);
  575. }
  576. videoAction('speed',value);
  577. break;
  578. case 'speedDown':
  579. if(video) {
  580. value = video.playbackRate - (video.playbackRate>1?0.25:0.1);
  581. if(value<0.1) video.playbackRate = 0.1;
  582. }
  583. else if(speed) {
  584. speed.step = speed.value>1?0.25:0.1;
  585. value = +speed.value - speed.step;
  586. }
  587. videoAction('speed', value);
  588. break;
  589. case 'speedOri':
  590. videoAction('speed', 1);
  591. break;
  592. case 'play':
  593. videoAction('play', value);
  594. break;
  595. case 'nextFrame':
  596. videoAction('step', 1/60);
  597. break;
  598. case 'preFrame':
  599. videoAction('step', -1/60);
  600. break;
  601. case 'forward':
  602. value = 1;
  603. if(ev.ctrlKey) value *= 5;
  604. if(ev.shiftKey) value *= 10;
  605. if(ev.altKey) value *= 60;
  606. videoAction('step', value);
  607. break;
  608. case 'backward':
  609. value = -1;
  610. if(ev.ctrlKey) value *= 5;
  611. if(ev.shiftKey) value *= 10;
  612. if(ev.altKey) value *= 60;
  613. videoAction('step', value);
  614. break;
  615. case 'capture':
  616. videoAction('shot', ev.shiftKey);
  617. break;
  618. case 'downCapture':
  619. videoAction('shot', true);
  620. break;
  621. default:
  622. break;
  623. }
  624. }
  625. //全局快捷键监听函数
  626. function keyListener(ev) {
  627. //console.log(ev,hoverItem);
  628. if (ev.target instanceof HTMLTextAreaElement||ev.target instanceof HTMLInputElement) return;
  629. const key = ev.key.toUpperCase();
  630. if (
  631. config.active.key == key
  632. &&config.active.shiftKey == ev.shiftKey
  633. &&config.active.ctrlKey == ev.ctrlKey
  634. &&config.active.altKey == ev.altKey
  635. ) {
  636. top.postMessage({
  637. action:'captureReport',
  638. about:'panelActive',
  639. id:window.captureId
  640. },'*');
  641. }
  642. else if (!hoverItem) return;
  643. let value;
  644. if(value = Object.entries(config).find(([k,v])=>k!='active'&&v.key==key&&!v.shiftKey!=ev.shiftKey&&!v.altKey!=ev.altKey&&!v.ctrlKey!=ev.ctrlKey)){
  645. //阻止覆盖网页原有快捷键
  646. if (config.stopPropagation.checked) {
  647. ev.stopPropagation();
  648. ev.stopImmediatePropagation();
  649. ev.preventDefault();
  650. }
  651. //将待操作视频暂时替换为鼠标悬停视频,并保存原视频备份
  652. const videoBk = video;
  653. if(hoverItem instanceof HTMLVideoElement) video = hoverItem;
  654. try{
  655. actionHandler(value[0], ev);
  656. }catch(e){
  657. console.error(e);
  658. }
  659. //操作完成将待操作视频还原为备份视频
  660. video = videoBk;
  661. }
  662. }
  663. document.addEventListener('keydown',keyListener,true);
  664.  
  665. //控制事件接收仅在iframe中执行
  666. if (window!=top) {
  667. window.addEventListener('message', function(ev) {
  668. //console.info('frame recive:',ev.data);
  669. if (ev.source!=window.parent || !ev.data.action) return;
  670. else if(ev.data.action=='captureDetech'){
  671. window.captureId = ev.data.id;
  672. videoDetech();
  673. }
  674. else if(ev.data.action=='reload'){
  675. getConfig();
  676. [].forEach.call(childs,(w,i)=>w.postMessage({action:'reload'},'*'));
  677. }else if(ev.data.action=='captureControl' && ev.data.target==window.captureId){
  678. videoAction(ev.data.todo, ev.data.value, ev.data.id);
  679. }
  680. });
  681. return;
  682. }
  683.  
  684. //以下UI控制界面及事件在iframe中不执行
  685. function toast(text,error){
  686. if(error) console.error(error);
  687. const toast = document.createElement('div');
  688. toast.style = `position: fixed;top: 50%;left: 50%;z-index: 2147483647;padding: 10px;background: darkcyan;transform: translate(-50%);color: #fff;border-radius: 6px;box-shadow:#666 0px 0px 4px`
  689. toast.innerText = text + (error||'');
  690. document.body.appendChild(toast);
  691. setTimeout(()=>toast.remove(),1000);
  692. }
  693. function dialogMove(ev){
  694. if (ev.type=='mousedown'){
  695. panel.tOffset = ev.pageY-panel.offsetTop;
  696. panel.lOffset = ev.pageX-panel.offsetLeft;
  697. document.body.addEventListener('mousemove',dialogMove);
  698. document.body.addEventListener('mouseup',dialogMove);
  699. }
  700. else if (ev.type=='mouseup'){
  701. document.body.removeEventListener('mousemove',dialogMove);
  702. document.body.removeEventListener('mouseup',dialogMove);
  703. }
  704. else{
  705. panel.style.top = ev.pageY-panel.tOffset+'px';
  706. panel.style.left = ev.pageX-panel.lOffset+'px';
  707. }
  708. }
  709. //简易创建页面元素的封装,方便嵌套,实际调用或许会发生相当奇怪问题……
  710. function _c(config){
  711. if(config instanceof Array) return config.map(_c);
  712. const item = document.createElement(config.nodeType);
  713. for(const i in config){
  714. if(i=='nodeType') continue;
  715. if(i=='childs' && config.childs instanceof Array) {
  716. config.childs.forEach(child=>{
  717. if(child instanceof HTMLElement) item.appendChild(child);
  718. else item.appendChild(_c(child));
  719. })
  720. continue;
  721. }
  722. else if(i=='parent') {
  723. config.parent.appendChild(item);
  724. continue;
  725. }
  726. else if(i=='for') {
  727. item.setAttribute('for',config[i]);
  728. }
  729. item[i] = config[i];
  730. }
  731. return item;
  732. }
  733.  
  734. let panel, selector, speed, play, settingForm;
  735. let loadSite, saveSite, clearSite;
  736.  
  737. //顶层窗体事件接收,处理来自子窗体汇报的数据状态
  738. function topReciver(ev) {
  739. //console.info('top recive:',ev.data);
  740. if (ev.data.action!='captureReport') return;
  741. if (ev.data.about=='videoNums') appendVideo(ev.data);
  742. else if (ev.data.about=='panelActive') panelActive();
  743. else if (ev.data.about=='videoStatus'&&selector.value.startsWith(ev.data.id)){
  744. play.innerText = ev.data.paused?"播放":"暂停";;
  745. speed.value = ev.data.speed;
  746. }
  747. }
  748. window.addEventListener('message', topReciver);
  749.  
  750. /**往工具栏下拉面板中添加视频
  751. * v: {id: 视频所处窗体ID, length: 视频数量, host: 视频所处窗体域名}
  752. * 当为顶层窗体时仅含有length成员
  753. **/
  754. function appendVideo(v){
  755. if (v&&v.length){
  756. for (let i=0;i<v.length;i++){
  757. _c({
  758. nodeType:'option',
  759. value:v.id!=undefined?v.id+'-'+i:i,
  760. innerText:v.id!=undefined?v.id+'-'+i:i,
  761. parent:selector
  762. })
  763. }
  764. }
  765. if (v.host) {
  766. let op = Array.from(loadSite.options).find(option=>option.value==v.host);
  767. if (op) {
  768. op.innerHTML = `【${v.id}】${op.value}`;
  769. }
  770. op = Array.from(clearSite.options).find(option=>option.value==v.host);
  771. if (op) {
  772. op.innerHTML = `【${v.id}】${op.value}`;
  773. }
  774. op = Array.from(saveSite.options).find(option=>option.value==v.host);
  775. if (op) {
  776. op.innerHTML = `【${v.id}】${op.value}`;
  777. }
  778. else {
  779. saveSite.add(_c({
  780. nodeType: 'option',
  781. value: v.host,
  782. innerHTML: `【${v.id}】${v.host}`
  783. }));
  784. }
  785. }
  786. }
  787.  
  788. //初始化创建工具栏面板
  789. function createPanel(){
  790. panel = _c({
  791. nodeType:'div',id:'HTML5VideoCapture',
  792. style:'position:fixed;top:40px;left:30px;z-index:2147483647;padding:5px 10px;background:darkcyan;font-family:initial;border-radius:4px;font-size:12px;text-align:left',
  793. onmouseenter:()=>(hoverItem = panel),
  794. onmouseleave:()=>(hoverItem = undefined),
  795. childs:[
  796. {
  797. nodeType:'style',
  798. innerHTML:'div#HTML5VideoCapture option{color:#000;}'
  799. + 'div#HTML5VideoCapture>*{margin:0 10px 5px 0}'
  800. + 'div#HTML5VideoCapture>span,div#HTML5VideoCapture>span>*{white-space:nowrap;}'
  801. + 'div#HTML5VideoCapture *{font-family:initial;color:#fff;background:transparent;line-height:20px;height:20px;box-sizing:content-box;vertical-align:top;}'
  802. + 'div#HTML5VideoCapture .h5vc-block {border:1px solid #ffffff99;border-radius:2px;padding:1px 4px;min-width:unset;margin-top:0}'
  803. + 'div#HTML5VideoCapture .h5vc-block:hover {border-color: #fff;}'
  804. + 'div#HTML5VideoCapture .setting-switcher:before {content:"﹀"}'
  805. + 'div#HTML5VideoCapture .setting-switcher.setting-open:before {content:"︿"}'
  806. + 'div#HTML5VideoCapture .setting-switcher:not(.setting-open)+form {display:none}'
  807. },
  808. {
  809. nodeType:'div',
  810. innerText:'HTML5视频截图工具',
  811. style:'cursor:move;user-select:none;font-size:14px;height:auto;min-width:60px;margin-right:0',
  812. onmousedown:dialogMove,
  813. ondblclick:()=>{
  814. speed.step = 0.25;
  815. speed.value = 1;
  816. actionHandler('speedOri');
  817. }
  818. },
  819. {
  820. nodeType:'button',
  821. className:'h5vc-block',
  822. innerText:'检测',
  823. title:'重新检测页面中的视频',
  824. onclick:videoDetech
  825. },
  826. selector = _c({
  827. nodeType:'select',
  828. className:'h5vc-block',
  829. title:'选择视频',
  830. style:'width:unset;min-width:30px',
  831. onchange: ()=>videoSelect(selector.value)
  832. }),
  833. speed = _c({
  834. nodeType:'input',
  835. className:'h5vc-block',
  836. type:'number',step:0.25,min:0,
  837. title:'视频速度,双击截图工具标题恢复原速',
  838. style:'width:40px;',
  839. oninput:()=> {
  840. speed.step = speed.value<1?0.1:0.25;
  841. videoAction('speed', +speed.value);
  842. }
  843. }),
  844. play = _c({
  845. nodeType:'button',
  846. className:'h5vc-block',
  847. innerText:'播放',
  848. onclick: ()=> actionHandler('play')
  849. }),
  850. {
  851. nodeType:'button',
  852. className:'h5vc-block',
  853. style:'margin-right:0',
  854. innerText:'<<',
  855. title:'后退1秒,按住shift 5倍,ctrl 10倍,alt 60倍,多按相乘',
  856. onclick:e=> actionHandler('backward', e)
  857. },
  858. {
  859. nodeType:'button',
  860. className:'h5vc-block',
  861. innerText:'<',
  862. title:'上一帧(1/60秒)',
  863. onclick:e=> actionHandler('preFrame', e)
  864. },
  865. {
  866. nodeType:'button',
  867. className:'h5vc-block',
  868. style:'margin-right:0',
  869. innerText:'截图',
  870. title:'新建标签页打开视频截图',
  871. onclick:e=> actionHandler('capture', e)
  872. },
  873. {
  874. nodeType:'button',
  875. className:'h5vc-block',
  876. innerText:'↓',
  877. title:'直接下载截图(如果可用)',
  878. onclick:()=> actionHandler('downCapture')
  879. },
  880. {
  881. nodeType:'button',
  882. className:'h5vc-block',
  883. style:'margin-right:0',
  884. innerText:'>',
  885. title:'下一帧(1/60秒)',
  886. onclick:e=> actionHandler('nextFrame')
  887. },
  888. {
  889. nodeType:'button',
  890. className:'h5vc-block',
  891. innerText:'>>',
  892. title:'前进1秒,按住shift 5倍,ctrl 10倍,alt 60倍,多按相乘',
  893. onclick:e=> actionHandler('forward', e)
  894. },
  895. {
  896. nodeType:'button',
  897. className:'h5vc-block',
  898. innerText:'关闭',
  899. title:'关闭截图工具栏',
  900. style:'margin-right:0',
  901. onclick:panelActive
  902. },
  903. //以下为快捷键设置和相关控制逻辑
  904. {
  905. nodeType:'div',className:'setting-switcher',
  906. style:'text-align: center;cursor: pointer;user-select: none;margin-bottom:0',
  907. onclick:(ev)=>ev.target.classList.toggle('setting-open')
  908. },
  909. settingForm = _c({
  910. nodeType:'form',
  911. style:'user-select: none;height: auto;overflow: hidden;margin-right: 0; font-size:12px',
  912. childs:[
  913. ...Object.entries(configList).map(([k,v])=>([
  914. {
  915. nodeType:'input',className:'h5vc-block',name:k,type:v.type,
  916. title:v.title,id:'h5vc-setting-'+k,style:'display: inline-block;-webkit-appearance: checkbox;',
  917. disabled:v.disabled,
  918. onclick:function(ev){this.select()&&ev.preventDefault()},
  919. onkeydown:function(ev){
  920. const key = ev.key;
  921. if (key!='Control' && key!='Shift' && key!='Alt') {
  922. this.value = (ev.ctrlKey&&'Ctrl+'||'')+(ev.shiftKey&&'Shift+'||'')+(ev.altKey&&'Alt+'||'')+ev.key.toUpperCase();
  923. }
  924. if(key!='Backspace' && key != 'Delete') ev.preventDefault();
  925. else this.value = '';
  926. },
  927. },
  928. {nodeType:'label',innerHTML:v.content,title:v.title,for:'h5vc-setting-'+k,style:'display:inline-block'},
  929. {nodeType:'br'}
  930. ])).flat(),
  931. {
  932. nodeType:'button',
  933. className:'h5vc-block',
  934. innerHTML:'保存默认',
  935. title:'保存通用默认设置,默认设置将在没有设置专用设置的网站生效',
  936. onclick:(ev)=> confirm('是否确认保存默认设置') &&
  937. checkSettingFrom()
  938. .then(config=>saveConfig(config,'default')&toast('保存成功'))
  939. .catch(e=>toast('保存错误',e))&ev.preventDefault()
  940. },
  941. {
  942. nodeType:'button',
  943. className:'h5vc-block',
  944. innerHTML:'重设默认',
  945. title:'重设默认设置为原始值,此操作不会改变其它专用网站设置',
  946. onclick:(ev)=> confirm('是否确认清除默认设置') &&
  947. saveConfig(undefined,'default')
  948. .then(()=>loadSettingForm(configList)&toast('重设成功'))
  949. .catch(e=>toast('重设错误',e))&ev.preventDefault()
  950. },
  951. loadSite = _c({
  952. nodeType:'select',className:'h5vc-block',
  953. style:'width:55px;',
  954. innerHTML:'<option value="">查看...</option>',
  955. title:'显示保存的设置内容,此网站中的内嵌网页域名会有与视频编号对应的标识',
  956. onchange:(ev)=>getConfig(ev.target.value).then(loadSettingForm)
  957. }),
  958. saveSite = _c({
  959. nodeType:'select', className:'h5vc-block',
  960. style:'width:70px;',
  961. innerHTML:'<option value="">保存为...</option><option value="'+location.host+'">'+location.host+'</option>',
  962. title:'保存网站专用设置,专用设置在该网站内将覆盖默认设置\n'
  963. +'仅显示在本网页上加载的域名,带有编号的是内嵌网页的域名,可根据视频编号识别',
  964. onchange:(ev)=> {
  965. const value = ev.target.value;
  966. ev.target.value = '';
  967. if (value=='' || !confirm('是否确认保存'+value+'设置')) {
  968. return;
  969. }
  970. checkSettingFrom().then(config=>{
  971. saveConfig(config, value);
  972. loadSettingForm(config);
  973. toast('保存成功');
  974. if (clearSite.querySelector(`[value="${value}"]`)) return;
  975. loadSite.add(_c({nodeType:'option',value:value,innerHTML:value}));
  976. clearSite.add(_c({nodeType:'option',value:value,innerHTML:value}))
  977. }).catch(e=>toast('保存错误',e))
  978. }
  979. }),
  980. clearSite = _c({
  981. nodeType:'select', className:'h5vc-block',
  982. style:'width:55px;',
  983. innerHTML:'<option value="">清除...</option>',
  984. title:'清除网站专用设置,清除之后默认设置内容将在该网站生效',
  985. onchange:(ev)=> {
  986. const value = ev.target.value;
  987. ev.target.value = '';
  988. if (!confirm('是否确认清除'+value+'设置')) return;
  989. saveConfig(undefined, value)
  990. .then(()=>loadSite.querySelector(`[value="${value}"]`))
  991. .then(find=>find&&loadSite.removeChild(find))
  992. .then(()=>clearSite.querySelector(`[value="${value}"]`))
  993. .then(find=>find&&clearSite.removeChild(find))
  994. .then(()=>loadSettingForm(config)&toast('清除成功'))
  995. .catch(e=>toast('清除错误',e))
  996. }
  997. }),
  998. ]
  999. })
  1000. ]
  1001. });
  1002.  
  1003. //初始化设置面板的配置
  1004. loadSite.add(_c({
  1005. nodeType: 'option',
  1006. innerHTML: '默认',
  1007. }));
  1008. for (const item in allConfigs) {
  1009. if (item=='default') continue;
  1010. loadSite.add(_c({
  1011. nodeType: 'option',
  1012. value: item,
  1013. innerHTML: item==location.host?'【△】'+item:item,
  1014. }));
  1015. clearSite.add(_c({
  1016. nodeType: 'option',
  1017. value: item,
  1018. innerHTML: item==location.host?'【△】'+item:item,
  1019. }));
  1020. }
  1021. loadSettingForm(config);
  1022. }
  1023.  
  1024. //检查并返回设置窗口中的设置,实际方法是同步的,设置成异步是为了方便.then
  1025. //throw: 检查设置不符合要求时抛出错误string,目前仅检测全局启动快捷键
  1026. async function checkSettingFrom() {
  1027. let value = cloneConfig(config);
  1028. for(let item of settingForm) {
  1029. if(value[item.name]) {
  1030. if(item.type=='checkbox') {
  1031. value[item.name].checked = item.checked;
  1032. }
  1033. else if(item.name){
  1034. let keys = item.value.split('+');
  1035. if(keys.length<2&&item.name=='active') throw '全局快捷键至少应当同时按下ctrl/shift/alt之一';
  1036. value[item.name].key = keys[keys.length-1];
  1037. value[item.name].ctrlKey = keys.indexOf('Ctrl')!=-1;
  1038. value[item.name].shiftKey = keys.indexOf('Shift')!=-1;
  1039. value[item.name].altKey = keys.indexOf('Alt')!=-1;
  1040. }
  1041. }
  1042. }
  1043. return value;
  1044. }
  1045.  
  1046. //给定一个设置,显示在设置面板中,读取设置时使用
  1047. function loadSettingForm(value) {
  1048. for(let item of settingForm) {
  1049. if(value[item.name]) {
  1050. if(item.type=='checkbox') {
  1051. item.checked = value[item.name].checked;
  1052. }
  1053. else {
  1054. let v = value[item.name];
  1055. item.value = (v.ctrlKey&&'Ctrl+'||'')+(v.shiftKey&&'Shift+'||'')+(v.altKey&&'Alt+'||'')+v.key.toUpperCase()
  1056. }
  1057. }
  1058. else {
  1059. if(item.type=='checkbox') {
  1060. item.checked = configList[item.name].checked;
  1061. }
  1062. else if (item.name){
  1063. let v = configList[item.name];
  1064. item.value = (v.ctrlKey&&'Ctrl+'||'')+(v.shiftKey&&'Shift+'||'')+(v.altKey&&'Alt+'||'')+v.key.toUpperCase()
  1065. }
  1066. }
  1067. }
  1068. loadSite.value = '';
  1069. }
  1070.  
  1071. function panelActive(){
  1072. if(document.body.contains(panel)) document.body.removeChild(panel);
  1073. else {
  1074. if(!panel) {
  1075. createPanel();
  1076. }
  1077. document.body.appendChild(panel);
  1078. if(!selector.length) videoDetech();
  1079. }
  1080. }
  1081. if ('function'==typeof(GM_registerMenuCommand) && window==top){
  1082. GM_registerMenuCommand('启用HTML5视频截图器',panelActive);
  1083. }
  1084. })();