Masked Watch

動画上のテキストや顔を検出してコメントを透過する

  1. // ==UserScript==
  2. // @name Masked Watch
  3. // @namespace https://github.com/segabito/
  4. // @description 動画上のテキストや顔を検出してコメントを透過する
  5. // @match *://www.nicovideo.jp/*
  6. // @match *://live.nicovideo.jp/*
  7. // @match *://anime.nicovideo.jp/*
  8. // @match *://embed.nicovideo.jp/watch/*
  9. // @match *://sp.nicovideo.jp/watch/*
  10. // @exclude *://ads*.nicovideo.jp/*
  11. // @exclude *://www.nicovideo.jp/favicon.ico*
  12. // @exclude *://www.nicovideo.jp/robots.txt*
  13. // @version 0.3.2
  14. // @grant none
  15. // @author 名無しさん
  16. // @license public domain
  17. // ==/UserScript==
  18. /* eslint-disable */
  19.  
  20. // chrome://flags/#enable-experimental-web-platform-features
  21.  
  22.  
  23. /**
  24. * @typedf BoundingBox
  25. * @property {number} x
  26. * @property {number} y
  27. * @property {number} width
  28. * @property {number} height
  29. * @property {'face'|'text'} type
  30. */
  31.  
  32.  
  33. (() => {
  34. const PRODUCT = 'MaskedWatch';
  35.  
  36. const monkey = (PRODUCT) => {
  37. 'use strict';
  38. var VER = '0.3.2';
  39. const ENV = 'STABLE';
  40.  
  41. let ZenzaWatch = null;
  42.  
  43. const DEFAULT_CONFIG = {
  44. interval: 300,
  45. enabled: true,
  46. debug: false,
  47. faceDetection: true,
  48. textDetection: !navigator.userAgent.toLowerCase().includes('windows'),
  49. fastMode: true,
  50. tmpWidth: 854,
  51. tmpHeight: 480,
  52. };
  53. const config = new class extends Function {
  54. toString() {
  55. return `
  56. *** CONFIG MENU (設定はサービスごとに保存) ***
  57. enabled: ${config.enabled}, // 有効/無効
  58. debug: ${config.debug}, // デバッグON/OFF
  59. faceDetection: ${config.faceDetection}, // 顔検出ON/OFF
  60. textDetection: ${config.textDetection}, // テキスト検出ON/OFF
  61. fastMode: ${config.fastMode}, // false 精度重視 true 速度重視
  62. tmpWidth: ${config.tmpWidth}, // 検出処理用キャンバスの横解像度
  63. tmpHeight: ${config.tmpHeight} // 検出処理用キャンバスの縦解像度
  64. interval: ${config.interval} // マスクの更新間隔
  65. `;
  66. }
  67. }, def = {};
  68. Object.keys(DEFAULT_CONFIG).sort().forEach(key => {
  69. const storageKey = `${PRODUCT}_${key}`;
  70. def[key] = {
  71. enumerable: true,
  72. get() {
  73. return localStorage.hasOwnProperty(storageKey) ?
  74. JSON.parse(localStorage[storageKey]) : DEFAULT_CONFIG[key];
  75. },
  76. set(value) {
  77. const currentValue = this[key];
  78. if (value === currentValue) {
  79. return;
  80. }
  81. if (value === DEFAULT_CONFIG[key]) {
  82. localStorage.removeItem(storageKey);
  83. } else {
  84. localStorage[storageKey] = JSON.stringify(value);
  85. }
  86. document.body.dispatchEvent(
  87. new CustomEvent(`${PRODUCT}-config.update`,
  88. {detail: {key, value, lastValue: currentValue}, bubbles: true, composed: true}
  89. ));
  90. }
  91. };
  92. });
  93. Object.defineProperties(config, def);
  94.  
  95. const MaskedWatch = window.MaskedWatch = { config };
  96.  
  97. const createWorker = (func, options = {}) => {
  98. const src = `(${func.toString()})(self);`;
  99. const blob = new Blob([src], {type: 'text/javascript'});
  100. const url = URL.createObjectURL(blob);
  101. return new Worker(url, options);
  102. };
  103. const bounce = {
  104. origin: Symbol('origin'),
  105. idle(func, time) {
  106. let reqId = null;
  107. let lastArgs = null;
  108. let promise = new PromiseHandler();
  109. const [caller, canceller] =
  110. (time === undefined && self.requestIdleCallback) ?
  111. [self.requestIdleCallback, self.cancelIdleCallback] : [self.setTimeout, self.clearTimeout];
  112. const callback = () => {
  113. const lastResult = func(...lastArgs);
  114. promise.resolve({lastResult, lastArgs});
  115. reqId = lastArgs = null;
  116. promise = new PromiseHandler();
  117. };
  118. const result = (...args) => {
  119. if (reqId) {
  120. reqId = canceller(reqId);
  121. }
  122. lastArgs = args;
  123. reqId = caller(callback, time);
  124. return promise;
  125. };
  126. result[this.origin] = func;
  127. return result;
  128. },
  129. time(func, time = 0) {
  130. return this.idle(func, time);
  131. }
  132. };
  133. const throttle = (func, interval) => {
  134. let lastTime = 0;
  135. let timer;
  136. let promise = new PromiseHandler();
  137. const result = (...args) => {
  138. if (timer) {
  139. return promise;
  140. }
  141. const now = performance.now();
  142. const timeDiff = now - lastTime;
  143. timer = setTimeout(() => {
  144. lastTime = performance.now();
  145. timer = null;
  146. const lastResult = func(...args);
  147. promise.resolve({lastResult, lastArgs: args});
  148. promise = new PromiseHandler();
  149. }, Math.max(interval - timeDiff, 0));
  150. return promise;
  151. };
  152. result.cancel = () => {
  153. if (timer) {
  154. timer = clearTimeout(timer);
  155. }
  156. promise.resolve({lastResult: null, lastArgs: null});
  157. promise = new PromiseHandler();
  158. };
  159. return result;
  160. };
  161. throttle.time = (func, interval = 0) => throttle(func, interval);
  162. throttle.raf = function(func) {
  163. // let promise;// = new PromiseHandler();
  164. let promise;
  165. let cancelled = false;
  166. const result = (...args) => {
  167. if (promise) {
  168. return promise;
  169. }
  170. if (!this.req) {
  171. this.req = new Promise(res => requestAnimationFrame(res)).then(() => {
  172. this.req = null;
  173. });
  174. }
  175. promise = this.req.then(() => {
  176. if (cancelled) {
  177. cancelled = false;
  178. return;
  179. }
  180. try { func(...args); } catch (e) { console.warn(e); }
  181. promise = null;
  182. });
  183. return promise;
  184. };
  185. result.cancel = () => {
  186. cancelled = true;
  187. promise = null;
  188. };
  189. return result;
  190. }.bind({req: null, count: 0, id: 0});
  191. throttle.idle = func => {
  192. let id;
  193. const request = (self.requestIdleCallback || self.setTimeout);
  194. const cancel = (self.cancelIdleCallback || self.clearTimeout);
  195. const result = (...args) => {
  196. if (id) {
  197. return;
  198. }
  199. id = request(() => {
  200. id = null;
  201. func(...args);
  202. }, 0);
  203. };
  204. result.cancel = () => {
  205. if (id) {
  206. id = cancel(id);
  207. }
  208. };
  209. return result;
  210. };
  211. const css = (() => {
  212. const setPropsTask = [];
  213. const applySetProps = throttle.raf(
  214. () => {
  215. const tasks = setPropsTask.concat();
  216. setPropsTask.length = 0;
  217. for (const [element, prop, value] of tasks) {
  218. try {
  219. element.style.setProperty(prop, value);
  220. } catch (error) {
  221. console.warn('element.style.setProperty fail', {element, prop, value, error});
  222. }
  223. }
  224. });
  225. const css = {
  226. addStyle: (styles, option, document = window.document) => {
  227. const elm = Object.assign(document.createElement('style'), {
  228. type: 'text/css'
  229. }, typeof option === 'string' ? {id: option} : (option || {}));
  230. if (typeof option === 'string') {
  231. elm.id = option;
  232. } else if (option) {
  233. Object.assign(elm, option);
  234. }
  235. elm.classList.add(global.PRODUCT);
  236. elm.append(styles.toString());
  237. (document.head || document.body || document.documentElement).append(elm);
  238. elm.disabled = option && option.disabled;
  239. elm.dataset.switch = elm.disabled ? 'off' : 'on';
  240. return elm;
  241. },
  242. registerProps(...args) {
  243. if (!CSS || !('registerProperty' in CSS)) {
  244. return;
  245. }
  246. for (const definition of args) {
  247. try {
  248. (definition.window || window).CSS.registerProperty(definition);
  249. } catch (err) { console.warn('CSS.registerProperty fail', definition, err); }
  250. }
  251. },
  252. setProps(...tasks) {
  253. setPropsTask.push(...tasks);
  254. return setPropsTask.length ? applySetProps() : Promise.resolve();
  255. },
  256. addModule: async function(func, options = {}) {
  257. if (!CSS || !('paintWorklet' in CSS) || this.set.has(func)) {
  258. return;
  259. }
  260. this.set.add(func);
  261. const src =
  262. `(${func.toString()})(
  263. this,
  264. registerPaint,
  265. ${JSON.stringify(options.config || {}, null, 2)}
  266. );`;
  267. const blob = new Blob([src], {type: 'text/javascript'});
  268. const url = URL.createObjectURL(blob);
  269. await CSS.paintWorklet.addModule(url).then(() => URL.revokeObjectURL(url));
  270. return true;
  271. }.bind({set: new WeakSet}),
  272. escape: value => CSS.escape ? CSS.escape(value) : value.replace(/([\.#()[\]])/g, '\\$1'),
  273. number: value => CSS.number ? CSS.number(value) : value,
  274. s: value => CSS.s ? CSS.s(value) : `${value}s`,
  275. ms: value => CSS.ms ? CSS.ms(value) : `${value}ms`,
  276. pt: value => CSS.pt ? CSS.pt(value) : `${value}pt`,
  277. px: value => CSS.px ? CSS.px(value) : `${value}px`,
  278. percent: value => CSS.percent ? CSS.percent(value) : `${value}%`,
  279. vh: value => CSS.vh ? CSS.vh(value) : `${value}vh`,
  280. vw: value => CSS.vw ? CSS.vw(value) : `${value}vw`,
  281. trans: value => self.CSSStyleValue ? CSSStyleValue.parse('transform', value) : value,
  282. word: value => self.CSSKeywordValue ? new CSSKeywordValue(value) : value,
  283. image: value => self.CSSStyleValue ? CSSStyleValue.parse('background-image', value) : value,
  284. };
  285. return css;
  286. })();
  287. const cssUtil = css;
  288.  
  289. const 業務 = function(self) {
  290. let fastMode, faceDetection, textDetection, debug, enabled;
  291. const init = params => {
  292. updateConfig({config: params.config});
  293. };
  294.  
  295. const updateConfig = ({config}) => {
  296. ({fastMode, faceDetection, textDetection, debug, enabled} = config);
  297. faceDetector = new (self || window).FaceDetector({fastMode});
  298. textDetector = new (self || window).TextDetector();
  299. };
  300.  
  301. let faceDetector;
  302. let textDetector;
  303. const detect = async ({bitmap}) => {
  304. // debug && console.time('detect');
  305. const tasks = [];
  306. faceDetection && (tasks.push(faceDetector.detect(bitmap).catch(() => [])));
  307. textDetection && (tasks.push(textDetector.detect(bitmap).catch(() => [])));
  308. const detected = (await Promise.all(tasks)).flat();
  309.  
  310. const boxes = detected.map(d => {
  311. const {x, y , width, height} = d.boundingBox;
  312. return {x, y , width, height, type: d.landmarks ? 'face' : 'text'};
  313. });
  314. // debug && console.timeEnd('detect');
  315. return {boxes};
  316. };
  317.  
  318. self.onmessage = async e => {
  319. const {command, params} = e.data.body;
  320. try {
  321. switch (command) {
  322. case 'init':
  323. init(params);
  324. self.postMessage({body: {command: 'init', params: {}, status: 'ok'}});
  325. break;
  326. case 'config':
  327. updateConfig(params);
  328. break;
  329. case 'detect': {
  330. const {dataURL, boxes} = await detect(params);
  331. self.postMessage({body: {command: 'data', params: {dataURL, boxes}, status: 'ok'}});
  332. }
  333. break;
  334. }
  335. } catch(err) {
  336. console.error('error', {command, params}, err);
  337. }
  338. };
  339. };
  340.  
  341. const 下請 = function(self, registerPaint, config) {
  342. registerPaint('塗装', class {
  343. static get inputProperties() {
  344. return ['--json-args', '--config'];
  345. }
  346. paint(ctx, {width, height}, props) {
  347. const args = JSON.parse(props.get('--json-args').toString() || '{}');
  348. const config = JSON.parse(props.get('--config').toString() || '{}');
  349.  
  350. ctx.beginPath();
  351. ctx.fillStyle = 'rgba(255, 255, 255, 1)';
  352. ctx.fillRect(0, 0, width, height);
  353. if (!args.history || !config.enabled) {
  354. return;
  355. }
  356.  
  357. const ratio = Math.min(width / config.tmpWidth, height / config.tmpHeight);
  358. const transX = (width - (config.tmpWidth * ratio)) / 2;
  359. const transY = (height - (config.tmpHeight * ratio)) / 2;
  360. const tmpArea = (config.tmpWidth * ratio) * (config.tmpHeight * ratio);
  361.  
  362.  
  363. /** @type {(BoundingBox[])[]} */
  364. const history = args.history;
  365. for (const boxes of history) {
  366. ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
  367. ctx.fillRect(0, 0, width, height);
  368.  
  369. for (const box of boxes) {
  370. let {x, y , width, height, type} = box;
  371. const area = width * height;
  372. const opacity = area / tmpArea * 0.3;
  373. ctx.fillStyle = `rgba(255, 255, 255, ${opacity})`;
  374.  
  375. x = x * ratio + transX;
  376. y = y * ratio + transY;
  377. width *= ratio;
  378. height *= ratio;
  379. if (type === 'face') {
  380. const mx = 16 * ratio, my = 24 * ratio; // margin
  381. ctx.clearRect(x - mx, y - my, width + mx * 2, height + my * 2);
  382. ctx.fillRect (x - mx, y - my, width + mx * 2, height + my * 2);
  383. } else {
  384. const mx = 16 * ratio, my = 16 * ratio; // margin
  385. ctx.clearRect(x - mx, y - my, width + mx * 2, height + my * 2);
  386. ctx.fillRect (x - mx, y - my, width + mx * 2, height + my * 2);
  387. }
  388. }
  389. }
  390. }
  391. });
  392. };
  393.  
  394. const createDetector = async ({video, layer, interval, type}) => {
  395. const worker = createWorker(業務, {name: 'Facelook'});
  396. await css.addModule(下請, {config: {...config}});
  397. const transferCanvas = new OffscreenCanvas(config.tmpWidth, config.tmpHeight);
  398. const ctx = transferCanvas.getContext('2d', {alpha: false, desynchronized: true});
  399. const debugLayer = document.createElement('div');
  400. [layer, debugLayer].forEach(layer => {
  401. layer.style.setProperty('--config', JSON.stringify({...config}));
  402. layer.style.setProperty('--json-args', '{}');
  403. });
  404. 'maskImage' in layer.style ?
  405. (layer.style.maskImage = 'paint(塗装)') :
  406. (layer.style.webkitMaskImage = 'paint(塗装)');
  407.  
  408. // for debug
  409. Object.assign(debugLayer.style, {
  410. border: '1px solid #888',
  411. left: 0,
  412. bottom: '48px',
  413. position: 'fixed',
  414. zIndex: '100000',
  415. width: '160px',
  416. height: '90px',
  417. opacity: 0.5,
  418. background: '#333',
  419. pointerEvents: 'none',
  420. userSelect: 'none'
  421. });
  422. debugLayer.classList.add('zen-family');
  423. debugLayer.dataset.type = type;
  424. debugLayer.style.backgroundImage = 'paint(塗装)';
  425. config.debug && document.body.append(debugLayer);
  426. worker.postMessage({body: {command: 'init', params: {config: {...config}}}});
  427.  
  428. let isBusy = true, currentTime = video.currentTime, boxHistory = [];
  429. worker.addEventListener('message', e => {
  430. const {command, params} = e.data.body;
  431. switch (command) {
  432. case 'init':
  433. console.log('initialized');
  434. isBusy = false;
  435. break;
  436. case 'data': {
  437. isBusy = false;
  438. if (!config.enabled) { return; }
  439. /** @type {BoundingBox[]} */
  440. const boxes = params.boxes;
  441. boxHistory.push(boxes);
  442. while (boxHistory.length > 5) { boxHistory.shift(); }
  443. const arg = JSON.stringify({
  444. tmpWidth: config.tmpWidth, tmpHeight: config.tmpHeight,
  445. history: boxHistory
  446. });
  447. layer.style.setProperty('--json-args', arg);
  448. config.debug && debugLayer.style.setProperty('--json-args', arg);
  449. }
  450. break;
  451. }
  452. });
  453.  
  454. const onTimer = () => {
  455. if (isBusy ||
  456. currentTime === video.currentTime ||
  457. document.visibilityState !== 'visible') {
  458. return;
  459. }
  460.  
  461. currentTime = video.currentTime;
  462. const vw = video.videoWidth, vh = video.videoHeight;
  463. const tmpWidth = config.tmpWidth, tmpHeight = config.tmpHeight;
  464. const ratio = Math.min(tmpWidth / vw, tmpHeight / vh);
  465. const dw = vw * ratio, dh = vh * ratio;
  466. ctx.beginPath();
  467. ctx.drawImage(
  468. video,
  469. 0, 0, vw, vh,
  470. (tmpWidth - dw) / 2, (tmpHeight - dh) / 2, dw, dh
  471. );
  472. const bitmap = transferCanvas.transferToImageBitmap();
  473. isBusy = true;
  474. worker.postMessage({body: {command: 'detect', params: {bitmap}}}, [bitmap]);
  475. };
  476. let timer = setInterval(onTimer, interval);
  477.  
  478. const start = () => timer = setInterval(onTimer, interval);
  479. const stop = () => timer = clearInterval(timer);
  480.  
  481. window.addEventListener(`${PRODUCT}-config.update`, e => {
  482. worker.postMessage({body: {command: 'config', params: {config: {...config}}}});
  483. const {key, value} = e.detail;
  484. layer.style.setProperty('--config', JSON.stringify({...config}));
  485. debugLayer.style.setProperty('--config', JSON.stringify({...config}));
  486. switch (key) {
  487. case 'enabled':
  488. value ? start() : stop();
  489. break;
  490. case 'debug':
  491. value ? document.body.append(debugLayer) : debugLayer.remove();
  492. break;
  493. case 'tmpWidth':
  494. transferCanvas.width = value;
  495. break;
  496. case 'tmpHeight':
  497. transferCanvas.height = value;
  498. break;
  499. }
  500. }, {passive: true});
  501. return { start, stop };
  502. };
  503.  
  504. const dialog = ((config) => {
  505. class MaskedWatchDialog extends HTMLElement {
  506. init() {
  507. if (this.shadow) { return; }
  508. this.shadow = this.attachShadow({mode: 'open'});
  509. this.shadow.innerHTML = this.getTemplate(config);
  510. this.root = this.shadow.querySelector('#root');
  511. this.shadow.querySelector('.close-button').addEventListener('click', e => {
  512. this.close(); e.stopPropagation(); e.preventDefault();
  513. });
  514. this.root.addEventListener('click', e => {
  515. if (e.target === this.root) { this.close(); }
  516. e.stopPropagation();
  517. });
  518. this.classList.add('zen-family');
  519. this.root.classList.add('zen-family');
  520. this.update();
  521.  
  522. this.root.addEventListener('change', e => {
  523. const input = e.target;
  524. const name = input.name;
  525. const value = JSON.parse(input.value);
  526. config.debug && console.log('update config', {name, value});
  527. config[name] = value;
  528. });
  529. }
  530. getTemplate(config) {
  531. return `
  532. <dialog id="root" class="root">
  533. <div>
  534. <style>
  535. .root {
  536. position: fixed;
  537. z-index: 10000;
  538. left: 0;
  539. top: 50%;
  540. transform: translate(0, -50%);
  541. background: rgba(240, 240, 240, 0.95);
  542. color: #222;
  543. padding: 16px 24px 8px;
  544. border: 0;
  545. user-select: none;
  546. box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.8);
  547. text-shadow: 1px 1px 0 #fff;
  548. border-radius: 4px;
  549. }
  550. .title {
  551. margin: 0;
  552. padding: 0 0 16px;
  553. font-size: 20px;
  554. text-align: center;
  555. }
  556. .config {
  557. padding: 0 0 16px;
  558. line-height: 20px;
  559. }
  560. .name {
  561. display: inline-block;
  562. min-width: 240px;
  563. white-space: nowrap;
  564. margin: 0;
  565. }
  566. label {
  567. display: inline-block;
  568. padding: 8px;
  569. line-height: 20px;
  570. min-width: 100px;
  571. border: 1px groove silver;
  572. border-radius: 4px;
  573. cursor: pointer;
  574. }
  575. label + label {
  576. margin-left: 8px;
  577. }
  578. label:hover {
  579. background: rgba(255, 255, 255, 1);
  580. }
  581. input[type=radio] {
  582. transform: scale(1.5);
  583. margin-right: 12px;
  584. }
  585. .close-button {
  586. display: block;
  587. margin: 8px auto 0;
  588. min-width: 180px;
  589. padding: 8px;
  590. font-size: 16px;
  591. border-radius: 4px;
  592. text-align: center;
  593. cursor: pointer;
  594. outline: none;
  595. }
  596. </style>
  597. <h1 class="title">††† Masked Watch 設定 †††</h1>
  598. <div class="config">
  599. <h3 class="name">顔の検出</h3>
  600. <label><input type="radio" name="faceDetection" value="true">ON</label>
  601. <label><input type="radio" name="faceDetection" value="false">OFF</label>
  602. </div>
  603.  
  604. <div class="config">
  605. <h3 class="name">テキストの検出<br>
  606. <span class="name" style="font-size: 80%;">windowsで動かないっぽい?</span>
  607. </h3>
  608. <label><input type="radio" name="textDetection" value="true">ON</label>
  609. <label><input type="radio" name="textDetection" value="false">OFF</label>
  610. </div>
  611.  
  612. <div class="config">
  613. <h3 class="name">動作モード</h3>
  614. <label><input type="radio" name="fastMode" value="true">速度重視</label>
  615. <label><input type="radio" name="fastMode" value="false">精度重視</label>
  616. </div>
  617.  
  618. <div class="config">
  619. <h3 class="name">デバッグ</h3>
  620. <label><input type="radio" name="debug" value="true">ON</label>
  621. <label><input type="radio" name="debug" value="false">OFF</label>
  622. </div>
  623.  
  624. <div class="config">
  625. <h3 class="name">MaskedWatch有効/無効</h3>
  626. <label><input type="radio" name="enabled" value="true">有効</label>
  627. <label><input type="radio" name="enabled" value="false">無効</label>
  628. </div>
  629. <div class="config">
  630. <button class="close-button">閉じる</button>
  631. </div>
  632. </div>
  633. </dialog>
  634. `;
  635. }
  636.  
  637. update() {
  638. this.init();
  639. [...this.shadow.querySelectorAll('input')].forEach(input => {
  640. const name = input.name, value = JSON.parse(input.value);
  641. input.checked = config[name] === value;
  642. });
  643. }
  644.  
  645. get isOpen() {
  646. return !!this.root && !!this.root.open;
  647. }
  648.  
  649. open() {
  650. this.update();
  651. this.root.showModal();
  652. }
  653.  
  654. close() {
  655. this.root && this.root.close();
  656. }
  657.  
  658. toggle() {
  659. this.init();
  660. if (this.isOpen) {
  661. this.root.close();
  662. } else {
  663. this.open();
  664. }
  665. }
  666. }
  667. window.customElements.define(`${PRODUCT.toLowerCase()}-dialog`, MaskedWatchDialog);
  668. return document.createElement(`${PRODUCT.toLowerCase()}-dialog`);
  669. })(config);
  670. MaskedWatch.dialog = dialog;
  671.  
  672. const createToggleButton = (config, dialog) => {
  673. class ToggleButton extends HTMLElement {
  674. constructor() {
  675. super();
  676. this.init();
  677. }
  678. init() {
  679. if (this.shadow) { return; }
  680. this.shadow = this.attachShadow({mode: 'open'});
  681. this.shadow.innerHTML = this.getTemplate(config);
  682. this.root = this.shadow.querySelector('#root');
  683. this.root.addEventListener('click', e => {
  684. dialog.toggle(); e.stopPropagation(); e.preventDefault();
  685. });
  686. }
  687. getTemplate() {
  688. return `
  689. <style>
  690. .controlButton {
  691. position: relative;
  692. display: inline-block;
  693. transition: opacity 0.4s ease, margin-left 0.2s ease, margin-top 0.2s ease;
  694. box-sizing: border-box;
  695. text-align: center;
  696. cursor: pointer;
  697. color: #fff;
  698. opacity: 0.8;
  699. vertical-align: middle;
  700. }
  701. .controlButton:hover {
  702. cursor: pointer;
  703. opacity: 1;
  704. }
  705. .controlButton .controlButtonInner {
  706. filter: grayscale(100%);
  707. }
  708. .switch {
  709. font-size: 16px;
  710. width: 32px;
  711. height: 32px;
  712. line-height: 30px;
  713. cursor: pointer;
  714. }
  715. .is-Enabled .controlButtonInner {
  716. color: #aef;
  717. filter: none;
  718. }
  719.  
  720. .controlButton .tooltip {
  721. display: none;
  722. pointer-events: none;
  723. position: absolute;
  724. left: 16px;
  725. top: -30px;
  726. transform: translate(-50%, 0);
  727. font-size: 12px;
  728. line-height: 16px;
  729. padding: 2px 4px;
  730. border: 1px solid !000;
  731. background: #ffc;
  732. color: #000;
  733. text-shadow: none;
  734. white-space: nowrap;
  735. z-index: 100;
  736. opacity: 0.8;
  737. }
  738.  
  739. .controlButton:hover {
  740. background: #222;
  741. }
  742.  
  743. .controlButton:hover .tooltip {
  744. display: block;
  745. opacity: 1;
  746. }
  747.  
  748. </style>
  749. <div id="root" class="switch controlButton root">
  750. <div class="controlButtonInner" title="MaskedWatch">&#9787;</div>
  751. <div class="tooltip">Masked Watch</div>
  752. </div>
  753. `;
  754. }
  755. }
  756. window.customElements.define(`${PRODUCT.toLowerCase()}-toggle-button`, ToggleButton);
  757. return document.createElement(`${PRODUCT.toLowerCase()}-toggle-button`);
  758. };
  759.  
  760. const ZenzaDetector = (() => {
  761. const promise =
  762. (window.ZenzaWatch && window.ZenzaWatch.ready) ?
  763. Promise.resolve(window.ZenzaWatch) :
  764. new Promise(resolve => {
  765. [window, (document.body || document.documentElement)]
  766. .forEach(e => e.addEventListener('ZenzaWatchInitialize', () => {
  767. resolve(window.ZenzaWatch);
  768. }, {once: true}));
  769. });
  770. return {detect: () => promise};
  771. })();
  772.  
  773. const vmap = new WeakMap();
  774. let timer;
  775. const watch = () => {
  776. if (!config.enabled || document.visibilityState !== 'visible') { return; }
  777. [...document.querySelectorAll('video, zenza-video')]
  778. .filter(video => !video.paused && !vmap.has(video))
  779. .forEach(video => {
  780. // 対応プレイヤー増やすならココ
  781. let layer, type = 'UNKNOWN';
  782. if (video.closest('#MainVideoPlayer')) {
  783. layer = document.querySelector('.CommentRenderer');
  784. type = 'NICO VIDEO';
  785. } else if (video.closest('#rootElementId')) {
  786. layer = document.querySelector('#comment canvas');
  787. type = 'NICO EMBED';
  788. } else if (video.closest('#watchVideoContainer')) {
  789. layer = document.querySelector('#jsPlayerCanvasComment canvas');
  790. type = 'NICO SP';
  791. } else if (video.closest('.zenzaPlayerContainer')) {
  792. layer = document.querySelector('.commentLayerFrame');
  793. type = 'ZenzaWatch';
  794. } else if (video.closest('[class*="__leo"]')) {
  795. layer = document.querySelector('#comment-layer-container canvas');
  796. type = 'NICO LIVE';
  797. } else if (video.closest('#bilibiliPlayer')) {
  798. layer = document.querySelector('.bilibili-player-video-danmaku').parentElement;
  799. type = 'BILI BILI [´ω`]';
  800. } else if (video.id === 'js-video') {
  801. layer = document.querySelector('#cmCanvas');
  802. type = 'HIMAWARI';
  803. }
  804.  
  805. console.log('%ctype: "%s"', 'font-weight: bold', layer ? type : 'UNKNOWN???');
  806. layer && Object.assign(layer.style, {
  807. backgroundSize: 'contain',
  808. maskSize: 'contain',
  809. webkitMaskSize: 'contain',
  810. maskRepeat: 'no-repeat',
  811. webkitMaskRepeat: 'no-repeat',
  812. maskPosition: 'center center',
  813. webkitMaskPosition: 'center center'
  814. });
  815. layer && video.dispatchEvent(
  816. new CustomEvent(`${PRODUCT}-start`,
  817. {detail: {type, video, layer}, bubbles: true, composed: true}
  818. ));
  819.  
  820. vmap.set(video,
  821. layer ?
  822. createDetector({video: video.drawableElement || video, layer, interval: config.interval, type}) :
  823. type
  824. );
  825. layer && !location.href.startsWith('https://www.nicovideo.jp/watch/') && clearInterval(timer);
  826. });
  827. };
  828.  
  829. const init = () => {
  830. css.registerProps(
  831. {name: '--json-args', syntax: '*', initialValue: '{}', inherits: false },
  832. {name: '--config', syntax: '*', initialValue: '{}', inherits: false }
  833. );
  834. timer = setInterval(watch, 1000);
  835.  
  836. document.body.append(dialog);
  837.  
  838. window.setTimeout(() => {
  839. const li = document.createElement('li');
  840. li.innerHTML = `<a href="javascript:;">${PRODUCT}設定</a>`;
  841. li.style.whiteSpace = 'nowrap';
  842. li.addEventListener('click', () => dialog.toggle());
  843. document.querySelector('#siteHeaderRightMenuContainer').append(li);
  844. }, document.querySelector('#siteHeaderRightMenuContainer') ? 1000 : 15000);
  845.  
  846. ZenzaDetector.detect().then(zen => {
  847. console.log('ZenzaWatch found ver.%s', zen.version);
  848. ZenzaWatch = zen;
  849. ZenzaWatch.emitter.promise('videoControBar.addonMenuReady').then(({container}) => {
  850. container.append(createToggleButton(config, dialog));
  851. });
  852. ZenzaWatch.emitter.promise('videoContextMenu.addonMenuReady.list').then(({container}) => {
  853. const faceMenu = document.createElement('li');
  854. faceMenu.className = 'command';
  855. faceMenu.dataset.command = 'nop';
  856. faceMenu.textContent = '顔の検出';
  857. faceMenu.classList.toggle('selected', config.faceDetection);
  858. faceMenu.addEventListener('click', () => {
  859. config.faceDetection = !config.faceDetection;
  860. });
  861. const textMenu = document.createElement('li');
  862. textMenu.className = 'command';
  863. textMenu.dataset.command = 'nop';
  864. textMenu.textContent = 'テキストの検出';
  865. textMenu.classList.toggle('selected', config.textDetection);
  866. textMenu.addEventListener('click', () => {
  867. config.textDetection = !config.textDetection;
  868. });
  869. ZenzaWatch.emitter.on('showMenu', () => {
  870. faceMenu.classList.toggle('selected', config.faceDetection);
  871. textMenu.classList.toggle('selected', config.textDetection);
  872. });
  873.  
  874. container.append(faceMenu, textMenu);
  875. });
  876.  
  877. });
  878. };
  879. init();
  880.  
  881. // eslint-disable-next-line no-undef
  882. console.log('%cMasked Watch', 'font-size: 200%;', `ver ${VER}`, '\nconfig: ', JSON.stringify({...config}));
  883. };
  884.  
  885. const loadGm = () => {
  886. const script = document.createElement('script');
  887. script.id = `${PRODUCT}Loader`;
  888. script.setAttribute('type', 'text/javascript');
  889. script.setAttribute('charset', 'UTF-8');
  890. script.append(`
  891. (() => {
  892. (${monkey.toString()})("${PRODUCT}");
  893. })();`);
  894. (document.head || document.documentElement).append(script);
  895. };
  896.  
  897. loadGm();
  898. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址