YouTube Viewfinding

Zoom, rotate & crop YouTube videos

当前为 2025-05-21 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube Viewfinding
  3. // @version 0.22
  4. // @description Zoom, rotate & crop YouTube videos
  5. // @author Callum Latham
  6. // @namespace https://gf.qytechs.cn/users/696211-ctl2
  7. // @license GNU GPLv3
  8. // @compatible chrome
  9. // @compatible edge
  10. // @compatible firefox Video dimensions affect page scrolling
  11. // @compatible opera Video dimensions affect page scrolling
  12. // @match *://www.youtube.com/*
  13. // @match *://youtube.com/*
  14. // @require https://update.gf.qytechs.cn/scripts/446506/1588535/%24Config.js
  15. // @grant GM.setValue
  16. // @grant GM.getValue
  17. // @grant GM.deleteValue
  18. // ==/UserScript==
  19.  
  20. /* global $Config */
  21.  
  22. (() => {
  23. const isEmbed = window.location.pathname.split('/')[1] === 'embed';
  24.  
  25. // Don't run in non-embed frames (e.g. stream chat frame)
  26. if (window.parent !== window && !isEmbed) {
  27. return;
  28. }
  29.  
  30. const VAR_ZOOM = '--viewfind-zoom';
  31. const LIMITS = {none: 'None', static: 'Static', fit: 'Fit'};
  32.  
  33. const $config = new $Config(
  34. 'VIEWFIND_TREE',
  35. (() => {
  36. const isCSSRule = (() => {
  37. const wrapper = document.createElement('style');
  38. const regex = /\s/g;
  39.  
  40. return (property, text) => {
  41. const ruleText = `${property}:${text};`;
  42.  
  43. document.head.appendChild(wrapper);
  44. wrapper.sheet.insertRule(`:not(*){${ruleText}}`);
  45.  
  46. const [{style: {cssText}}] = wrapper.sheet.cssRules;
  47.  
  48. wrapper.remove();
  49.  
  50. return cssText.replaceAll(regex, '') === ruleText.replaceAll(regex, '') || `Must be a valid CSS ${property} rule`;
  51. };
  52. })();
  53.  
  54. const getHideId = (() => {
  55. let id = -1;
  56.  
  57. return () => ++id;
  58. })();
  59.  
  60. const glowHideId = getHideId();
  61.  
  62. return {
  63. get: (_, configs) => Object.assign(...configs),
  64. children: [
  65. {
  66. label: 'Controls',
  67. children: [
  68. {
  69. label: 'Keybinds',
  70. descendantPredicate: ([actions, reset, configure]) => {
  71. const keybinds = [...actions.children.slice(1), reset, configure].map(({children}) => children.filter(({value}) => value !== '').map(({value}) => value));
  72.  
  73. for (let i = 0; i < keybinds.length - 1; ++i) {
  74. for (let j = i + 1; j < keybinds.length; ++j) {
  75. if (keybinds[i].length === keybinds[j].length && keybinds[i].every((keyA) => keybinds[j].some((keyB) => keyA === keyB))) {
  76. return 'Another action has this keybind';
  77. }
  78. }
  79. }
  80.  
  81. return true;
  82. },
  83. get: (_, configs) => ({keys: Object.assign(...configs)}),
  84. children: (() => {
  85. const seed = {
  86. value: '',
  87. listeners: {
  88. keydown: (event) => {
  89. switch (event.key) {
  90. case 'Enter':
  91. case 'Escape':
  92. return;
  93. }
  94.  
  95. event.preventDefault();
  96.  
  97. event.target.value = event.code;
  98.  
  99. event.target.dispatchEvent(new InputEvent('input'));
  100. },
  101. },
  102. };
  103.  
  104. const getKeys = (children) => new Set(children.filter(({value}) => value !== '').map(({value}) => value));
  105.  
  106. const getNode = (label, keys, get) => ({
  107. label,
  108. seed,
  109. children: keys.map((value) => ({...seed, value})),
  110. get,
  111. });
  112.  
  113. return [
  114. {
  115. label: 'Actions',
  116. get: (_, [toggle, ...controls]) => Object.assign(...controls.map(({id, keys}) => ({
  117. [id]: {
  118. toggle,
  119. keys,
  120. },
  121. }))),
  122. children: [
  123. {
  124. label: 'Toggle?',
  125. value: false,
  126. get: ({value}) => value,
  127. },
  128. ...[
  129. ['Pan / Zoom', ['KeyZ'], 'pan'],
  130. ['Rotate', ['IntlBackslash'], 'rotate'],
  131. ['Crop', ['KeyZ', 'IntlBackslash'], 'crop'],
  132. ].map(([label, keys, id]) => getNode(label, keys, ({children}) => ({id, keys: getKeys(children)}))),
  133. ],
  134. },
  135. getNode('Reset', ['KeyX'], ({children}) => ({reset: {keys: getKeys(children)}})),
  136. getNode('Configure', ['AltLeft', 'KeyX'], ({children}) => ({config: {keys: getKeys(children)}})),
  137. ];
  138. })(),
  139. },
  140. {
  141. label: 'Scroll Speeds',
  142. get: (_, configs) => ({speeds: Object.assign(...configs)}),
  143. children: [
  144. {
  145. label: 'Zoom',
  146. value: -100,
  147. get: ({value}) => ({zoom: value / 150000}),
  148. },
  149. {
  150. label: 'Rotate',
  151. value: -100,
  152. // 150000 * (5 - 0.8) / 2π ≈ 100000
  153. get: ({value}) => ({rotate: value / 100000}),
  154. },
  155. {
  156. label: 'Crop',
  157. value: -100,
  158. get: ({value}) => ({crop: value / 300000}),
  159. },
  160. ],
  161. },
  162. {
  163. label: 'Drag Inversions',
  164. get: (_, configs) => ({multipliers: Object.assign(...configs)}),
  165. children: [
  166. ['Pan', 'pan'],
  167. ['Rotate', 'rotate'],
  168. ['Crop', 'crop'],
  169. ].map(([label, key, value = false]) => ({
  170. label,
  171. value,
  172. get: ({value}) => ({[key]: value ? -1 : 1}),
  173. })),
  174. },
  175. {
  176. label: 'Click Movement Allowance (px)',
  177. value: 2,
  178. predicate: (value) => value >= 0 || 'Allowance must be positive',
  179. inputAttributes: {min: 0},
  180. get: ({value: clickCutoff}) => ({clickCutoff}),
  181. },
  182. ],
  183. },
  184. {
  185. label: 'Behaviour',
  186. children: [
  187. ...(() => {
  188. const typeNode = {
  189. label: 'Type',
  190. get: ({value}) => ({type: value}),
  191. };
  192.  
  193. const hiddenNodes = {
  194. [LIMITS.static]: {
  195. label: 'Value (%)',
  196. predicate: (value) => value >= 0 || 'Limit must be positive',
  197. inputAttributes: {min: 0},
  198. get: ({value}) => ({custom: value / 100}),
  199. },
  200. [LIMITS.fit]: {
  201. label: 'Glow Allowance (%)',
  202. predicate: (value) => value >= 0 || 'Allowance must be positive',
  203. inputAttributes: {min: 0},
  204. get: ({value}) => ({frame: value / 100}),
  205. },
  206. };
  207.  
  208. const getNode = (label, key, value, options, ...hidden) => {
  209. const hideIds = {};
  210. const children = [{...typeNode, value, options}];
  211.  
  212. for (const {id, value} of hidden) {
  213. const node = {...hiddenNodes[id], value, hideId: getHideId()};
  214.  
  215. hideIds[node.hideId] = id;
  216.  
  217. children.push(node);
  218. }
  219.  
  220. if (hidden.length > 0) {
  221. children[0].onUpdate = (value) => {
  222. const hide = {};
  223.  
  224. for (const [id, type] of Object.entries(hideIds)) {
  225. hide[id] = value !== type;
  226. }
  227.  
  228. return {hide};
  229. };
  230. }
  231.  
  232. return {
  233. label,
  234. get: (_, configs) => ({[key]: Object.assign(...configs)}),
  235. children,
  236. };
  237. };
  238.  
  239. return [
  240. getNode(
  241. 'Zoom In Limit',
  242. 'zoomInLimit',
  243. LIMITS.static,
  244. [LIMITS.none, LIMITS.static, LIMITS.fit],
  245. {id: LIMITS.static, value: 500},
  246. {id: LIMITS.fit, value: 0},
  247. ),
  248. getNode(
  249. 'Zoom Out Limit',
  250. 'zoomOutLimit',
  251. LIMITS.static,
  252. [LIMITS.none, LIMITS.static, LIMITS.fit],
  253. {id: LIMITS.static, value: 80},
  254. {id: LIMITS.fit, value: 300},
  255. ),
  256. getNode(
  257. 'Pan Limit',
  258. 'panLimit',
  259. LIMITS.static,
  260. [LIMITS.none, LIMITS.static, LIMITS.fit],
  261. {id: LIMITS.static, value: 50},
  262. ),
  263. getNode(
  264. 'Snap Pan Limit',
  265. 'snapPanLimit',
  266. LIMITS.fit,
  267. [LIMITS.none, LIMITS.fit],
  268. ),
  269. ];
  270. })(),
  271. {
  272. label: 'While Viewfinding',
  273. get: (_, configs) => {
  274. const {overlayKill, overlayHide, ...config} = Object.assign(...configs);
  275.  
  276. return {
  277. active: {
  278. overlayRule: overlayKill && [overlayHide ? 'display' : 'pointer-events', 'none'],
  279. ...config,
  280. },
  281. };
  282. },
  283. children: [
  284. {
  285. label: 'Pause Video?',
  286. value: false,
  287. get: ({value: pause}) => ({pause}),
  288. },
  289. {
  290. label: 'Hide Glow?',
  291. value: false,
  292. get: ({value: hideGlow}) => ({hideGlow}),
  293. hideId: glowHideId,
  294. },
  295. ...((hideId) => [
  296. {
  297. label: 'Disable Overlay?',
  298. value: true,
  299. get: ({value: overlayKill}, configs) => Object.assign({overlayKill}, ...configs),
  300. onUpdate: (value) => ({hide: {[hideId]: !value}}),
  301. children: [
  302. {
  303. label: 'Hide Overlay?',
  304. value: false,
  305. get: ({value: overlayHide}) => ({overlayHide}),
  306. hideId,
  307. },
  308. ],
  309. },
  310. ])(getHideId()),
  311. ],
  312. },
  313.  
  314. ],
  315. },
  316. {
  317. label: 'Glow',
  318. value: true,
  319. onUpdate: (value) => ({hide: {[glowHideId]: !value}}),
  320. get: ({value: on}, configs) => {
  321. if (!on) {
  322. return {};
  323. }
  324.  
  325. const {turnover, ...config} = Object.assign(...configs);
  326. const sampleCount = Math.floor(config.fps * turnover);
  327.  
  328. // avoid taking more samples than there's space for
  329. if (sampleCount > config.size) {
  330. const fps = config.size / turnover;
  331.  
  332. return {
  333. glow: {
  334. ...config,
  335. sampleCount: config.size,
  336. interval: 1000 / fps,
  337. fps,
  338. },
  339. };
  340. }
  341.  
  342. return {
  343. glow: {
  344. ...config,
  345. interval: 1000 / config.fps,
  346. sampleCount,
  347. },
  348. };
  349. },
  350. children: [
  351. (() => {
  352. const [seed, getChild] = (() => {
  353. const options = ['blur', 'brightness', 'contrast', 'drop-shadow', 'grayscale', 'hue-rotate', 'invert', 'opacity', 'saturate', 'sepia'];
  354. const ids = {};
  355. const hide = {};
  356.  
  357. for (const option of options) {
  358. ids[option] = getHideId();
  359.  
  360. hide[ids[option]] = true;
  361. }
  362.  
  363. const min0Amount = {
  364. label: 'Amount (%)',
  365. value: 100,
  366. predicate: (value) => value >= 0 || 'Amount must be positive',
  367. inputAttributes: {min: 0},
  368. };
  369.  
  370. const max100Amount = {
  371. label: 'Amount (%)',
  372. value: 0,
  373. predicate: (value) => {
  374. if (value < 0) {
  375. return 'Amount must be positive';
  376. }
  377.  
  378. return value <= 100 || 'Amount may not exceed 100%';
  379. },
  380. inputAttributes: {min: 0, max: 100},
  381. };
  382.  
  383. const getScaled = (value) => `calc(${value}px/var(${VAR_ZOOM}))`;
  384.  
  385. const root = {
  386. label: 'Function',
  387. options,
  388. value: options[0],
  389. get: ({value}, configs) => {
  390. const config = Object.assign(...configs);
  391.  
  392. switch (value) {
  393. case options[0]:
  394. return {
  395. filter: config.blurScale ? `blur(${config.blur}px)` : `blur(${getScaled(config.blur)})`,
  396. blur: {
  397. x: config.blur,
  398. y: config.blur,
  399. scale: config.blurScale,
  400. },
  401. };
  402.  
  403. case options[3]:
  404. return {
  405. filter: config.shadowScale ?
  406. `drop-shadow(${config.shadow} ${config.shadowX}px ${config.shadowY}px ${config.shadowSpread}px)` :
  407. `drop-shadow(${config.shadow} ${getScaled(config.shadowX)} ${getScaled(config.shadowY)} ${getScaled(config.shadowSpread)})`,
  408. blur: {
  409. x: config.shadowSpread + Math.abs(config.shadowX),
  410. y: config.shadowSpread + Math.abs(config.shadowY),
  411. scale: config.shadowScale,
  412. },
  413. };
  414.  
  415. case options[5]:
  416. return {filter: `hue-rotate(${config.hueRotate}deg)`};
  417. }
  418.  
  419. return {filter: `${value}(${config[value]}%)`};
  420. },
  421. onUpdate: (value) => ({hide: {...hide, [ids[value]]: false}}),
  422. };
  423.  
  424. const children = {
  425. 'blur': [
  426. {
  427. label: 'Distance (px)',
  428. value: 0,
  429. get: ({value}) => ({blur: value}),
  430. predicate: (value) => value >= 0 || 'Distance must be positive',
  431. inputAttributes: {min: 0},
  432. hideId: ids.blur,
  433. },
  434. {
  435. label: 'Scale?',
  436. value: false,
  437. get: ({value}) => ({blurScale: value}),
  438. hideId: ids.blur,
  439. },
  440. ],
  441. 'brightness': [
  442. {
  443. ...min0Amount,
  444. hideId: ids.brightness,
  445. get: ({value}) => ({brightness: value}),
  446. },
  447. ],
  448. 'contrast': [
  449. {
  450. ...min0Amount,
  451. hideId: ids.contrast,
  452. get: ({value}) => ({contrast: value}),
  453. },
  454. ],
  455. 'drop-shadow': [
  456. {
  457. label: 'Colour',
  458. input: 'color',
  459. value: '#FFFFFF',
  460. get: ({value}) => ({shadow: value}),
  461. hideId: ids['drop-shadow'],
  462. },
  463. {
  464. label: 'Horizontal Offset (px)',
  465. value: 0,
  466. get: ({value}) => ({shadowX: value}),
  467. hideId: ids['drop-shadow'],
  468. },
  469. {
  470. label: 'Vertical Offset (px)',
  471. value: 0,
  472. get: ({value}) => ({shadowY: value}),
  473. hideId: ids['drop-shadow'],
  474. },
  475. {
  476. label: 'Spread (px)',
  477. value: 0,
  478. predicate: (value) => value >= 0 || 'Spread must be positive',
  479. inputAttributes: {min: 0},
  480. get: ({value}) => ({shadowSpread: value}),
  481. hideId: ids['drop-shadow'],
  482. },
  483. {
  484. label: 'Scale?',
  485. value: true,
  486. get: ({value}) => ({shadowScale: value}),
  487. hideId: ids['drop-shadow'],
  488. },
  489. ],
  490. 'grayscale': [
  491. {
  492. ...max100Amount,
  493. hideId: ids.grayscale,
  494. get: ({value}) => ({grayscale: value}),
  495. },
  496. ],
  497. 'hue-rotate': [
  498. {
  499. label: 'Angle (deg)',
  500. value: 0,
  501. get: ({value}) => ({hueRotate: value}),
  502. hideId: ids['hue-rotate'],
  503. },
  504. ],
  505. 'invert': [
  506. {
  507. ...max100Amount,
  508. hideId: ids.invert,
  509. get: ({value}) => ({invert: value}),
  510. },
  511. ],
  512. 'opacity': [
  513. {
  514. ...max100Amount,
  515. value: 100,
  516. hideId: ids.opacity,
  517. get: ({value}) => ({opacity: value}),
  518. },
  519. ],
  520. 'saturate': [
  521. {
  522. ...min0Amount,
  523. hideId: ids.saturate,
  524. get: ({value}) => ({saturate: value}),
  525. },
  526. ],
  527. 'sepia': [
  528. {
  529. ...max100Amount,
  530. hideId: ids.sepia,
  531. get: ({value}) => ({sepia: value}),
  532. },
  533. ],
  534. };
  535.  
  536. return [
  537. {...root, children: Object.values(children).flat()}, (id, ...values) => {
  538. const replacements = [];
  539.  
  540. for (const [i, child] of children[id].entries()) {
  541. replacements.push({...child, value: values[i]});
  542. }
  543.  
  544. return {
  545. ...root,
  546. value: id,
  547. children: Object.values({...children, [id]: replacements}).flat(),
  548. };
  549. },
  550. ];
  551. })();
  552.  
  553. return {
  554. label: 'Filter',
  555. get: (_, configs) => {
  556. const scaled = {x: 0, y: 0};
  557. const unscaled = {x: 0, y: 0};
  558.  
  559. let filter = '';
  560.  
  561. for (const config of configs) {
  562. filter += config.filter;
  563.  
  564. if ('blur' in config) {
  565. const target = config.blur.scale ? scaled : unscaled;
  566.  
  567. target.x = Math.max(target.x, config.blur.x);
  568. target.y = Math.max(target.y, config.blur.y);
  569. }
  570. }
  571.  
  572. return {filter, blur: {scaled, unscaled}};
  573. },
  574. children: [
  575. getChild('saturate', 150),
  576. getChild('brightness', 150),
  577. getChild('blur', 25, false),
  578. ],
  579. seed,
  580. };
  581. })(),
  582. {
  583. label: 'Update',
  584. childPredicate: ([{value: fps}, {value: turnover}]) => fps * turnover >= 1 || `${turnover} second turnover cannot be achieved at ${fps} hertz`,
  585. children: [
  586. {
  587. label: 'Frequency (Hz)',
  588. value: 15,
  589. predicate: (value) => {
  590. if (value > 144) {
  591. return 'Update frequency may not be above 144 hertz';
  592. }
  593.  
  594. return value >= 0 || 'Update frequency must be positive';
  595. },
  596. inputAttributes: {min: 0, max: 144},
  597. get: ({value: fps}) => ({fps}),
  598. },
  599. {
  600. label: 'Turnover Time (s)',
  601. value: 3,
  602. predicate: (value) => value >= 0 || 'Turnover time must be positive',
  603. inputAttributes: {min: 0},
  604. get: ({value: turnover}) => ({turnover}),
  605. },
  606. {
  607. label: 'Reverse?',
  608. value: false,
  609. get: ({value: doFlip}) => ({doFlip}),
  610. },
  611. ],
  612. },
  613. {
  614. label: 'Size (px)',
  615. value: 50,
  616. predicate: (value) => value >= 0 || 'Size must be positive',
  617. inputAttributes: {min: 0},
  618. get: ({value}) => ({size: value}),
  619. },
  620. {
  621. label: 'End Point (%)',
  622. value: 103,
  623. predicate: (value) => value >= 0 || 'End point must be positive',
  624. inputAttributes: {min: 0},
  625. get: ({value}) => ({end: value / 100}),
  626. },
  627. ].map((node) => ({...node, hideId: glowHideId})),
  628. },
  629. {
  630. label: 'Interfaces',
  631. children: [
  632. {
  633. label: 'Crop',
  634. get: (_, configs) => ({crop: Object.assign(...configs)}),
  635. children: [
  636. {
  637. label: 'Colours',
  638. get: (_, configs) => ({colour: Object.assign(...configs)}),
  639. children: [
  640. {
  641. label: 'Fill',
  642. get: (_, [colour, opacity]) => ({fill: `${colour}${opacity}`}),
  643. children: [
  644. {
  645. label: 'Colour',
  646. value: '#808080',
  647. input: 'color',
  648. get: ({value}) => value,
  649. },
  650. {
  651. label: 'Opacity (%)',
  652. value: 40,
  653. predicate: (value) => {
  654. if (value < 0) {
  655. return 'Opacity must be positive';
  656. }
  657.  
  658. return value <= 100 || 'Opacity may not exceed 100%';
  659. },
  660. inputAttributes: {min: 0, max: 100},
  661. get: ({value}) => Math.round(255 * value / 100).toString(16),
  662. },
  663. ],
  664. },
  665. {
  666. label: 'Shadow',
  667. value: '#000000',
  668. input: 'color',
  669. get: ({value: shadow}) => ({shadow}),
  670. },
  671. {
  672. label: 'Border',
  673. value: '#ffffff',
  674. input: 'color',
  675. get: ({value: border}) => ({border}),
  676. },
  677. ],
  678. },
  679. {
  680. label: 'Handle Size (%)',
  681. value: 6,
  682. predicate: (value) => {
  683. if (value < 0) {
  684. return 'Size must be positive';
  685. }
  686.  
  687. return value <= 50 || 'Size may not exceed 50%';
  688. },
  689. inputAttributes: {min: 0, max: 50},
  690. get: ({value}) => ({handle: value / 100}),
  691. },
  692. ],
  693. },
  694. {
  695. label: 'Crosshair',
  696. get: (value, configs) => ({crosshair: Object.assign(...configs)}),
  697. children: [
  698. {
  699. label: 'Outer Thickness (px)',
  700. value: 3,
  701. predicate: (value) => value >= 0 || 'Thickness must be positive',
  702. inputAttributes: {min: 0},
  703. get: ({value: outer}) => ({outer}),
  704. },
  705. {
  706. label: 'Inner Thickness (px)',
  707. value: 1,
  708. predicate: (value) => value >= 0 || 'Thickness must be positive',
  709. inputAttributes: {min: 0},
  710. get: ({value: inner}) => ({inner}),
  711. },
  712. {
  713. label: 'Inner Diameter (px)',
  714. value: 157,
  715. predicate: (value) => value >= 0 || 'Diameter must be positive',
  716. inputAttributes: {min: 0},
  717. get: ({value: gap}) => ({gap}),
  718. },
  719. ((hideId) => ({
  720. label: 'Text',
  721. value: true,
  722. onUpdate: (value) => ({hide: {[hideId]: !value}}),
  723. get: ({value}, configs) => {
  724. if (!value) {
  725. return {};
  726. }
  727.  
  728. const {translateX, translateY, ...config} = Object.assign(...configs);
  729.  
  730. return {
  731. text: {
  732. translate: {
  733. x: translateX,
  734. y: translateY,
  735. },
  736. ...config,
  737. },
  738. };
  739. },
  740. children: [
  741. {
  742. label: 'Font',
  743. value: '30px "Harlow Solid", cursive',
  744. predicate: isCSSRule.bind(null, 'font'),
  745. get: ({value: font}) => ({font}),
  746. },
  747. {
  748. label: 'Position (%)',
  749. get: (_, configs) => ({position: Object.assign(...configs)}),
  750. children: ['x', 'y'].map((label) => ({
  751. label,
  752. value: 0,
  753. predicate: (value) => Math.abs(value) <= 50 || 'Position must be on-screen',
  754. inputAttributes: {min: -50, max: 50},
  755. get: ({value}) => ({[label]: value + 50}),
  756. })),
  757. },
  758. {
  759. label: 'Offset (px)',
  760. get: (_, configs) => ({offset: Object.assign(...configs)}),
  761. children: [
  762. {
  763. label: 'x',
  764. value: -6,
  765. get: ({value: x}) => ({x}),
  766. },
  767. {
  768. label: 'y',
  769. value: -25,
  770. get: ({value: y}) => ({y}),
  771. },
  772. ],
  773. },
  774. (() => {
  775. const options = ['Left', 'Center', 'Right'];
  776.  
  777. return {
  778. label: 'Alignment',
  779. value: options[2],
  780. options,
  781. get: ({value}) => ({align: value.toLowerCase(), translateX: options.indexOf(value) * -50}),
  782. };
  783. })(),
  784. (() => {
  785. const options = ['Top', 'Middle', 'Bottom'];
  786.  
  787. return {
  788. label: 'Baseline',
  789. value: options[0],
  790. options,
  791. get: ({value}) => ({translateY: options.indexOf(value) * -50}),
  792. };
  793. })(),
  794. {
  795. label: 'Line height (%)',
  796. value: 90,
  797. predicate: (value) => value >= 0 || 'Height must be positive',
  798. inputAttributes: {min: 0},
  799. get: ({value}) => ({height: value / 100}),
  800. },
  801. ].map((node) => ({...node, hideId})),
  802. }))(getHideId()),
  803. {
  804. label: 'Colours',
  805. get: (_, configs) => ({colour: Object.assign(...configs)}),
  806. children: [
  807. {
  808. label: 'Fill',
  809. value: '#ffffff',
  810. input: 'color',
  811. get: ({value: fill}) => ({fill}),
  812. },
  813. {
  814. label: 'Shadow',
  815. value: '#000000',
  816. input: 'color',
  817. get: ({value: shadow}) => ({shadow}),
  818. },
  819. ],
  820. },
  821. ],
  822. },
  823. ],
  824. },
  825. ],
  826. };
  827. })(),
  828. {
  829. defaultStyle: {
  830. headBase: '#c80000',
  831. headButtonExit: '#000000',
  832. borderHead: '#ffffff',
  833. borderTooltip: '#c80000',
  834. width: Math.min(90, screen.width / 16),
  835. height: 90,
  836. },
  837. outerStyle: {
  838. zIndex: 10000,
  839. scrollbarColor: 'initial',
  840. },
  841. patches: [
  842. // removing "Glow Allowance" from pan limits
  843. ({children: [, {children}]}) => {
  844. // pan
  845. children[2].children.splice(2, 1);
  846. // snap pan
  847. children[3].children.splice(1, 1);
  848. },
  849. ],
  850. },
  851. );
  852.  
  853. const CLASS_VIEWFINDER = 'viewfind-element';
  854. const DEGREES = {
  855. 45: Math.PI / 4,
  856. 90: Math.PI / 2,
  857. 180: Math.PI,
  858. 270: Math.PI / 2 * 3,
  859. 360: Math.PI * 2,
  860. };
  861. const SELECTOR_VIDEO = '#movie_player video.html5-main-video';
  862.  
  863. // STATE
  864.  
  865. // elements
  866. let video;
  867. let altTarget;
  868. let viewport;
  869. let cinematics;
  870.  
  871. // derived values
  872. let videoTheta;
  873. let videoHypotenuse;
  874. let isThin;
  875. let viewportTheta;
  876. let viewportRatio;
  877. let viewportRatioInverse;
  878. const halfDimensions = {
  879. video: {},
  880. viewport: {},
  881. };
  882.  
  883. // other
  884. let stopped = true;
  885. let stopDrag;
  886.  
  887. const handleVideoChange = () => {
  888. DimensionCache.id++;
  889.  
  890. halfDimensions.video.width = video.clientWidth / 2;
  891. halfDimensions.video.height = video.clientHeight / 2;
  892.  
  893. videoTheta = getTheta(0, 0, video.clientWidth, video.clientHeight);
  894. videoHypotenuse = Math.sqrt(halfDimensions.video.width * halfDimensions.video.width + halfDimensions.video.height * halfDimensions.video.height);
  895. };
  896.  
  897. const handleViewportChange = () => {
  898. DimensionCache.id++;
  899.  
  900. isThin = getTheta(0, 0, viewport.clientWidth, viewport.clientHeight) < videoTheta;
  901.  
  902. halfDimensions.viewport.width = viewport.clientWidth / 2;
  903. halfDimensions.viewport.height = viewport.clientHeight / 2;
  904.  
  905. viewportTheta = getTheta(0, 0, viewport.clientWidth, viewport.clientHeight);
  906. viewportRatio = viewport.clientWidth / viewport.clientHeight;
  907. viewportRatioInverse = 1 / viewportRatio;
  908.  
  909. position.constrain();
  910.  
  911. glow.handleViewChange(true);
  912. };
  913.  
  914. // ROTATION HELPERS
  915.  
  916. const getTheta = (fromX, fromY, toX, toY) => Math.atan2(toY - fromY, toX - fromX);
  917.  
  918. const getRotatedCorners = (radius, theta) => {
  919. const angle0 = DEGREES[90] - theta + rotation.value;
  920. const angle1 = theta + rotation.value - DEGREES[90];
  921.  
  922. return [
  923. {
  924. x: Math.abs(radius * Math.cos(angle0)),
  925. y: Math.abs(radius * Math.sin(angle0)),
  926. },
  927. {
  928. x: Math.abs(radius * Math.cos(angle1)),
  929. y: Math.abs(radius * Math.sin(angle1)),
  930. },
  931. ];
  932. };
  933.  
  934. // CSS HELPER
  935.  
  936. const css = new function () {
  937. this.has = (name) => document.body.classList.contains(name);
  938. this.tag = (name, doAdd = true) => document.body.classList[doAdd ? 'add' : 'remove'](name);
  939.  
  940. this.getSelector = (...classes) => `body.${classes.join('.')}`;
  941.  
  942. const getSheet = () => {
  943. const element = document.createElement('style');
  944.  
  945. document.head.appendChild(element);
  946.  
  947. return element.sheet;
  948. };
  949.  
  950. const getRuleString = (selector, ...declarations) => `${selector}{${declarations.map(([property, value]) => `${property}:${value};`).join('')}}`;
  951.  
  952. this.add = function (...rule) {
  953. this.insertRule(getRuleString(...rule));
  954. }.bind(getSheet());
  955.  
  956. this.Toggleable = class {
  957. static sheet = getSheet();
  958.  
  959. static active = [];
  960.  
  961. static id = 0;
  962.  
  963. static add(rule, id) {
  964. this.sheet.insertRule(rule, this.active.length);
  965.  
  966. this.active.push(id);
  967. }
  968.  
  969. static remove(id) {
  970. let index = this.active.indexOf(id);
  971.  
  972. while (index >= 0) {
  973. this.sheet.deleteRule(index);
  974.  
  975. this.active.splice(index, 1);
  976.  
  977. index = this.active.indexOf(id);
  978. }
  979. }
  980.  
  981. id = this.constructor.id++;
  982.  
  983. add(...rule) {
  984. this.constructor.add(getRuleString(...rule), this.id);
  985. }
  986.  
  987. remove() {
  988. this.constructor.remove(this.id);
  989. }
  990. };
  991. }();
  992.  
  993. // ACTION MANAGER
  994.  
  995. const enabler = new function () {
  996. this.CLASS_ABLE = 'viewfind-action-able';
  997. this.CLASS_DRAGGING = 'viewfind-action-dragging';
  998.  
  999. this.keys = new Set();
  1000.  
  1001. this.didPause = false;
  1002. this.isHidingGlow = false;
  1003.  
  1004. this.setActive = (action) => {
  1005. const {active, keys} = $config.get();
  1006.  
  1007. if (active.hideGlow && Boolean(action) !== this.isHidingGlow) {
  1008. if (action) {
  1009. this.isHidingGlow = true;
  1010.  
  1011. glow.hide();
  1012. } else if (this.isHidingGlow) {
  1013. this.isHidingGlow = false;
  1014.  
  1015. glow.show();
  1016. }
  1017. }
  1018.  
  1019. this.activeAction?.onInactive?.();
  1020.  
  1021. if (action) {
  1022. this.activeAction = action;
  1023. this.toggled = keys[action.CODE].toggle;
  1024.  
  1025. action.onActive?.();
  1026.  
  1027. if (active.pause && !video.paused) {
  1028. video.pause();
  1029.  
  1030. this.didPause = true;
  1031. }
  1032.  
  1033. return;
  1034. }
  1035.  
  1036. if (this.didPause) {
  1037. video.play();
  1038.  
  1039. this.didPause = false;
  1040. }
  1041.  
  1042. this.activeAction = this.toggled = undefined;
  1043. };
  1044.  
  1045. this.handleChange = () => {
  1046. if (stopped || stopDrag || video.ended) {
  1047. return;
  1048. }
  1049.  
  1050. const {keys} = $config.get();
  1051.  
  1052. let activeAction;
  1053.  
  1054. for (const action of Object.values(actions)) {
  1055. if (
  1056. keys[action.CODE].keys.size === 0 || !this.keys.isSupersetOf(keys[action.CODE].keys) || activeAction && ('toggle' in keys[action.CODE] ?
  1057. !('toggle' in keys[activeAction.CODE]) || keys[activeAction.CODE].keys.size >= keys[action.CODE].keys.size :
  1058. !('toggle' in keys[activeAction.CODE]) && keys[activeAction.CODE].keys.size >= keys[action.CODE].keys.size)
  1059. ) {
  1060. if ('CLASS_ABLE' in action) {
  1061. css.tag(action.CLASS_ABLE, false);
  1062. }
  1063.  
  1064. continue;
  1065. }
  1066.  
  1067. if (activeAction && 'CLASS_ABLE' in activeAction) {
  1068. css.tag(activeAction.CLASS_ABLE, false);
  1069. }
  1070.  
  1071. activeAction = action;
  1072. }
  1073.  
  1074. if (activeAction === this.activeAction) {
  1075. return;
  1076. }
  1077.  
  1078. if (activeAction) {
  1079. if ('CLASS_ABLE' in activeAction) {
  1080. css.tag(activeAction.CLASS_ABLE);
  1081.  
  1082. css.tag(this.CLASS_ABLE);
  1083.  
  1084. this.setActive(activeAction);
  1085.  
  1086. return;
  1087. }
  1088.  
  1089. this.activeAction?.onInactive?.();
  1090.  
  1091. activeAction.onActive();
  1092.  
  1093. this.activeAction = activeAction;
  1094. }
  1095.  
  1096. css.tag(this.CLASS_ABLE, false);
  1097.  
  1098. this.setActive(false);
  1099. };
  1100.  
  1101. this.stop = () => {
  1102. css.tag(this.CLASS_ABLE, false);
  1103.  
  1104. for (const action of Object.values(actions)) {
  1105. if ('CLASS_ABLE' in action) {
  1106. css.tag(action.CLASS_ABLE, false);
  1107. }
  1108. }
  1109.  
  1110. this.setActive(false);
  1111. };
  1112.  
  1113. this.updateConfig = (() => {
  1114. const rule = new css.Toggleable();
  1115. const selector = `${css.getSelector(this.CLASS_ABLE)} #contentContainer.tp-yt-app-drawer[swipe-open]::after`
  1116. + `,${css.getSelector(this.CLASS_ABLE)} #movie_player > .html5-video-container ~ :not(.${CLASS_VIEWFINDER})`;
  1117.  
  1118. return () => {
  1119. const {overlayRule} = $config.get().active;
  1120.  
  1121. rule.remove();
  1122.  
  1123. if (overlayRule) {
  1124. rule.add(selector, overlayRule);
  1125. }
  1126. };
  1127. })();
  1128.  
  1129. $config.ready.then(() => {
  1130. this.updateConfig();
  1131. });
  1132.  
  1133. // insertion order decides priority
  1134. css.add(`${css.getSelector(this.CLASS_DRAGGING)} #movie_player`, ['cursor', 'grabbing']);
  1135. css.add(`${css.getSelector(this.CLASS_ABLE)} #movie_player`, ['cursor', 'grab']);
  1136. }();
  1137.  
  1138. // ELEMENT CONTAINER SETUP
  1139.  
  1140. const containers = new function () {
  1141. for (const name of ['background', 'foreground', 'tracker']) {
  1142. this[name] = document.createElement('div');
  1143.  
  1144. this[name].classList.add(CLASS_VIEWFINDER);
  1145. }
  1146.  
  1147. // make an outline of the uncropped video
  1148. css.add(`${css.getSelector(enabler.CLASS_ABLE)} #${this.foreground.id = 'viewfind-outlined'}`, ['outline', '1px solid white']);
  1149.  
  1150. this.background.style.position = this.foreground.style.position = 'absolute';
  1151. this.background.style.pointerEvents = this.foreground.style.pointerEvents = this.tracker.style.pointerEvents = 'none';
  1152. this.tracker.style.height = this.tracker.style.width = '100%';
  1153. }();
  1154.  
  1155. // MODIFIERS
  1156.  
  1157. class Cache {
  1158. targets = [];
  1159.  
  1160. constructor(...targets) {
  1161. for (const source of targets) {
  1162. this.targets.push({source});
  1163. }
  1164. }
  1165.  
  1166. update(target) {
  1167. return target.value !== (target.value = target.source.value);
  1168. }
  1169.  
  1170. isStale() {
  1171. return this.targets.reduce((value, target) => value || this.update(target), false);
  1172. }
  1173. }
  1174.  
  1175. class ConfigCache extends Cache {
  1176. static id = 0;
  1177.  
  1178. id = this.constructor.id;
  1179.  
  1180. constructor(...targets) {
  1181. super(...targets);
  1182. }
  1183.  
  1184. isStale() {
  1185. if (this.id === (this.id = this.constructor.id)) {
  1186. return super.isStale();
  1187. }
  1188.  
  1189. for (const target of this.targets) {
  1190. target.value = target.source.value;
  1191. }
  1192.  
  1193. return true;
  1194. }
  1195. }
  1196.  
  1197. class DimensionCache extends ConfigCache {
  1198. static id = 0;
  1199. }
  1200.  
  1201. const rotation = new function () {
  1202. this.value = DEGREES[90];
  1203.  
  1204. this.reset = () => {
  1205. this.value = DEGREES[90];
  1206.  
  1207. video.style.removeProperty('rotate');
  1208. };
  1209.  
  1210. this.apply = () => {
  1211. // Conversion from anticlockwise rotation from the x-axis to clockwise rotation from the y-axis
  1212. video.style.setProperty('rotate', `${DEGREES[90] - this.value}rad`);
  1213.  
  1214. delete actions.reset.restore;
  1215. };
  1216.  
  1217. // dissimilar from other constrain functions in that no effective limit is applied
  1218. // -1.5π < rotation <= 0.5π
  1219. // 0 <= 0.5π - rotation < 2π
  1220. this.constrain = () => {
  1221. this.value %= DEGREES[360];
  1222.  
  1223. if (this.value > DEGREES[90]) {
  1224. this.value -= DEGREES[360];
  1225. } else if (this.value <= -DEGREES[270]) {
  1226. this.value += DEGREES[360];
  1227. }
  1228.  
  1229. this.apply();
  1230. };
  1231. }();
  1232.  
  1233. const zoom = new function () {
  1234. this.value = 1;
  1235.  
  1236. const scaleRule = new css.Toggleable();
  1237.  
  1238. this.reset = () => {
  1239. this.value = 1;
  1240.  
  1241. video.style.removeProperty('scale');
  1242.  
  1243. scaleRule.remove();
  1244. scaleRule.add(':root', [VAR_ZOOM, '1']);
  1245. };
  1246.  
  1247. this.apply = () => {
  1248. video.style.setProperty('scale', `${this.value}`);
  1249.  
  1250. scaleRule.remove();
  1251. scaleRule.add(':root', [VAR_ZOOM, `${this.value}`]);
  1252.  
  1253. delete actions.reset.restore;
  1254. };
  1255.  
  1256. const getFit = (corner0, corner1, doSplit = false) => {
  1257. const x = Math.max(corner0.x, corner1.x) / viewport.clientWidth;
  1258. const y = Math.max(corner0.y, corner1.y) / viewport.clientHeight;
  1259.  
  1260. return doSplit ? [0.5 / x, 0.5 / y] : 0.5 / Math.max(x, y);
  1261. };
  1262.  
  1263. this.getFit = (width, height) => getFit(...getRotatedCorners(Math.sqrt(width * width + height * height), getTheta(0, 0, width, height)));
  1264. this.getVideoFit = (doSplit) => getFit(...getRotatedCorners(videoHypotenuse, videoTheta), doSplit);
  1265.  
  1266. this.constrain = (() => {
  1267. const limitGetters = {
  1268. [LIMITS.static]: [({custom}) => custom, ({custom}) => custom],
  1269. [LIMITS.fit]: (() => {
  1270. const getGetter = () => {
  1271. const zoomCache = new Cache(this);
  1272. const rotationCache = new DimensionCache(rotation);
  1273. const configCache = new ConfigCache();
  1274.  
  1275. let updateOnZoom;
  1276.  
  1277. let value;
  1278.  
  1279. return ({frame}, glow) => {
  1280. let fallthrough = rotationCache.isStale();
  1281.  
  1282. if (configCache.isStale()) {
  1283. if (glow) {
  1284. const {scaled} = glow.blur;
  1285.  
  1286. updateOnZoom = frame > 0 && (scaled.x > 0 || scaled.y > 0);
  1287. } else {
  1288. updateOnZoom = false;
  1289. }
  1290.  
  1291. fallthrough = true;
  1292. }
  1293.  
  1294. if (zoomCache.isStale() && updateOnZoom || fallthrough) {
  1295. if (glow) {
  1296. const base = glow.end - 1;
  1297. const {scaled, unscaled} = glow.blur;
  1298.  
  1299. value = this.getFit(
  1300. halfDimensions.video.width + Math.max(0, base * halfDimensions.video.width + Math.max(unscaled.x, scaled.x * this.value)) * frame,
  1301. halfDimensions.video.height + Math.max(0, base * halfDimensions.video.height + Math.max(unscaled.y, scaled.y * this.value)) * frame,
  1302. );
  1303. } else {
  1304. value = this.getVideoFit();
  1305. }
  1306. }
  1307.  
  1308. return value;
  1309. };
  1310. };
  1311.  
  1312. return [getGetter(), getGetter()];
  1313. })(),
  1314. };
  1315.  
  1316. return () => {
  1317. const {zoomOutLimit, zoomInLimit, glow} = $config.get();
  1318.  
  1319. if (zoomOutLimit.type !== 'None') {
  1320. this.value = Math.max(limitGetters[zoomOutLimit.type][0](zoomOutLimit, glow), this.value);
  1321. }
  1322.  
  1323. if (zoomInLimit.type !== 'None') {
  1324. this.value = Math.min(limitGetters[zoomInLimit.type][1](zoomInLimit, glow, 1), this.value);
  1325. }
  1326.  
  1327. this.apply();
  1328. };
  1329. })();
  1330. }();
  1331.  
  1332. const position = new function () {
  1333. this.x = this.y = 0;
  1334.  
  1335. this.getValues = () => ({x: this.x, y: this.y});
  1336.  
  1337. this.reset = () => {
  1338. this.x = this.y = 0;
  1339.  
  1340. video.style.removeProperty('translate');
  1341. };
  1342.  
  1343. this.apply = () => {
  1344. video.style.setProperty('transform-origin', `${(0.5 + this.x) * 100}% ${(0.5 - this.y) * 100}%`);
  1345. video.style.setProperty('translate', `${-this.x * 100}% ${this.y * 100}%`);
  1346.  
  1347. delete actions.reset.restore;
  1348. };
  1349.  
  1350. this.constrain = (() => {
  1351. // logarithmic progress from "low" to infinity
  1352. const getProgress = (low, target) => 1 - low / target;
  1353.  
  1354. const getProgressed = ({x: fromX, y: fromY, z: lowZ}, {x: toX, y: toY}, targetZ) => {
  1355. const p = getProgress(lowZ, targetZ);
  1356.  
  1357. return {x: p * (toX - fromX) + fromX, y: p * (toY - fromY) + fromY};
  1358. };
  1359.  
  1360. // y = mx + c
  1361. const getLineY = ({m, c}, x = this.x) => m * x + c;
  1362. // x = (y - c) / m
  1363. const getLineX = ({m, c}, y = this.y) => (y - c) / m;
  1364.  
  1365. const getM = (from, to) => (to.y - from.y) / (to.x - from.x);
  1366. const getLine = (m, {x, y}) => ({c: y - m * x, m});
  1367. const getFlipped = ({x, y}) => ({x: -x, y: -y});
  1368.  
  1369. const correctY = (line, left, right) => {
  1370. if (this.x >= left.x && this.x <= right.x) {
  1371. this.y = getLineY(line, this.x);
  1372.  
  1373. return true;
  1374. }
  1375. };
  1376.  
  1377. const correctX = (line, bottom, top) => {
  1378. if (this.y >= bottom.y && this.y <= top.y) {
  1379. this.x = getLineX(line, this.y);
  1380.  
  1381. return true;
  1382. }
  1383. };
  1384.  
  1385. const isAbove = ({m, c}, {x, y} = this) => m * x + c < y;
  1386. const isRight = ({m, c}, {x, y} = this) => (y - c) / m < x;
  1387.  
  1388. const apply2DFrame = (points, lines) => {
  1389. const {x, y} = this;
  1390.  
  1391. if (Math.abs(lines.right.c) === Infinity) {
  1392. this.x = Math.min(points.topRight.x, Math.max(points.topLeft.x, this.x));
  1393. } else if (isRight(lines.right)) {
  1394. if (correctX(lines.right, points.bottomRight, points.topRight)) {
  1395. return;
  1396. }
  1397. } else if (!isRight(lines.left)) {
  1398. if (correctX(lines.left, points.bottomLeft, points.topLeft)) {
  1399. return;
  1400. }
  1401. }
  1402.  
  1403. if (isAbove(lines.top)) {
  1404. if (correctY(lines.top, points.topLeft, points.topRight)) {
  1405. return;
  1406. }
  1407. } else if (!isAbove(lines.bottom)) {
  1408. if (correctY(lines.bottom, points.bottomLeft, points.bottomRight)) {
  1409. return;
  1410. }
  1411. }
  1412.  
  1413. if (x <= points.bottomLeft.x && y <= points.bottomLeft.y) {
  1414. this.x = points.bottomLeft.x;
  1415. this.y = points.bottomLeft.y;
  1416. } else if (x >= points.bottomRight.x && y <= points.bottomRight.y) {
  1417. this.x = points.bottomRight.x;
  1418. this.y = points.bottomRight.y;
  1419. } else if (x <= points.topLeft.x && y >= points.topLeft.y) {
  1420. this.x = points.topLeft.x;
  1421. this.y = points.topLeft.y;
  1422. } else if (x >= points.topRight.x && y >= points.topRight.y) {
  1423. this.x = points.topRight.x;
  1424. this.y = points.topRight.y;
  1425. }
  1426. };
  1427.  
  1428. const apply1DSideFrame = {
  1429. x: (line) => {
  1430. this.x = Math.max(-line.x, Math.min(line.x, this.x));
  1431.  
  1432. this.y = getLineY(line);
  1433. },
  1434. y: (line) => {
  1435. this.y = Math.max(-line.y, Math.min(line.y, this.y));
  1436.  
  1437. this.x = getLineX(line);
  1438. },
  1439. };
  1440.  
  1441. const swap = (array, i0, i1) => {
  1442. const temp = array[i0];
  1443.  
  1444. array[i0] = array[i1];
  1445. array[i1] = temp;
  1446. };
  1447.  
  1448. const getBoundApplyFrame = (() => {
  1449. const getBound = (first, second, isTopLeft) => {
  1450. if (zoom.value <= first.z) {
  1451. return false;
  1452. }
  1453.  
  1454. if (zoom.value >= second.z) {
  1455. const progress = zoom.value / second.z;
  1456.  
  1457. const x = isTopLeft ?
  1458. -0.5 - (-0.5 - second.x) / progress :
  1459. 0.5 - (0.5 - second.x) / progress;
  1460.  
  1461. return {
  1462. x,
  1463. y: 0.5 - (0.5 - second.y) / progress,
  1464. };
  1465. }
  1466.  
  1467. return {
  1468. ...getProgressed(first, second.vpEnd, zoom.value),
  1469. axis: second.vpEnd.axis,
  1470. m: second.y / second.x,
  1471. c: 0,
  1472. };
  1473. };
  1474.  
  1475. const getFrame = (point0, point1) => {
  1476. const points = {};
  1477. const lines = {};
  1478.  
  1479. const flipped0 = getFlipped(point0);
  1480. const flipped1 = getFlipped(point1);
  1481.  
  1482. const m0 = getM(point0, point1);
  1483. const m1 = getM(flipped0, point1);
  1484.  
  1485. lines.top = getLine(m0, point0);
  1486. lines.bottom = getLine(m0, flipped0);
  1487.  
  1488. lines.left = getLine(m1, point0);
  1489. lines.right = getLine(m1, flipped0);
  1490.  
  1491. points.topLeft = point0;
  1492. points.topRight = point1;
  1493. points.bottomLeft = flipped1;
  1494. points.bottomRight = flipped0;
  1495.  
  1496. if (video.clientWidth < video.clientHeight) {
  1497. if (getLineX(lines.right, 0) < getLineX(lines.left, 0)) {
  1498. swap(lines, 'right', 'left');
  1499.  
  1500. swap(points, 'bottomLeft', 'bottomRight');
  1501. swap(points, 'topLeft', 'topRight');
  1502. }
  1503. } else {
  1504. if (lines.top.c < lines.bottom.c) {
  1505. swap(lines, 'top', 'bottom');
  1506.  
  1507. swap(points, 'topLeft', 'bottomLeft');
  1508. swap(points, 'topRight', 'bottomRight');
  1509. }
  1510. }
  1511.  
  1512. return [points, lines];
  1513. };
  1514.  
  1515. return (first0, second0, first1, second1) => {
  1516. const point0 = getBound(first0, second0, true);
  1517. const point1 = getBound(first1, second1, false);
  1518.  
  1519. if (!point0 && !point1) {
  1520. return () => {
  1521. this.x = this.y = 0;
  1522. };
  1523. }
  1524.  
  1525. if (!point0 || !point1 || point0.axis && point0.axis === point1.axis) {
  1526. // todo choose the longer line?
  1527. const point = point0 || point1;
  1528. const {axis} = point;
  1529.  
  1530. point.axis ??= Math.abs(point.x) > Math.abs(point.y) ? 'x' : 'y';
  1531.  
  1532. if (point[point.axis] < 0) {
  1533. point.x = -point.x;
  1534. point.y = -point.y;
  1535. }
  1536.  
  1537. if (!axis) {
  1538. point.m = point.y / point.x;
  1539.  
  1540. point.c = 0;
  1541. }
  1542.  
  1543. return apply1DSideFrame[point.axis].bind(null, point);
  1544. }
  1545.  
  1546. return apply2DFrame.bind(null, ...getFrame(point0, point1));
  1547. };
  1548. })();
  1549.  
  1550. const snapZoom = (() => {
  1551. const getDirected = (first, second, flipX, flipY) => {
  1552. const line0 = [first, {}];
  1553. const line1 = [{z: second.z}, {}];
  1554.  
  1555. if (flipX) {
  1556. line0[1].x = -second.vpEnd.x;
  1557. line1[0].x = -second.x;
  1558. line1[1].x = -0.5;
  1559. } else {
  1560. line0[1].x = second.vpEnd.x;
  1561. line1[0].x = second.x;
  1562. line1[1].x = 0.5;
  1563. }
  1564.  
  1565. if (flipY) {
  1566. line0[1].y = -second.vpEnd.y;
  1567. line1[0].y = -second.y;
  1568. line1[1].y = -0.5;
  1569. } else {
  1570. line0[1].y = second.vpEnd.y;
  1571. line1[0].y = second.y;
  1572. line1[1].y = 0.5;
  1573. }
  1574.  
  1575. return [line0, line1];
  1576. };
  1577.  
  1578. // https://math.stackexchange.com/questions/2223691/intersect-2-lines-at-the-same-ratio-through-a-point
  1579. const getIntersectProgress = ({x, y}, [{x: g, y: e}, {x: f, y: d}], [{x: k, y: i}, {x: j, y: h}], doFlip) => {
  1580. const a = d * j - d * k - j * e + e * k - h * f + h * g + i * f - i * g;
  1581. const b = d * k - d * x - e * k + e * x + j * e - k * e - j * y + k * y - h * g + h * x + i * g - i * x - f * i + g * i + f * y - g * y;
  1582. const c = k * e - e * x - k * y - g * i + i * x + g * y;
  1583.  
  1584. return (doFlip ? -b - Math.sqrt(b * b - 4 * a * c) : -b + Math.sqrt(b * b - 4 * a * c)) / (2 * a);
  1585. };
  1586.  
  1587. const getLineFromPoints = (from, to) => getLine(getM(from, to), from);
  1588.  
  1589. // line with progressed start point
  1590. const getProgressedLine = (line, {z}) => [getProgressed(...line, z), line[1]];
  1591.  
  1592. return (first0, _second0, first1, second1) => {
  1593. const second0 = {..._second0, x: -_second0.x, vpEnd: {..._second0.vpEnd, x: -_second0.vpEnd.x}};
  1594.  
  1595. const absPosition = {x: Math.abs(this.x), y: Math.abs(this.y)};
  1596.  
  1597. const getPairings = (flipX0, flipY0, flipX1, flipY1) => {
  1598. const [lineFirst0, lineSecond0] = getDirected(first0, second0, flipX0, flipY0);
  1599. const [lineFirst1, lineSecond1] = getDirected(first1, second1, flipX1, flipY1);
  1600.  
  1601. // array structure is:
  1602. // start zoom for both lines
  1603. // 0 line start and its infinite zoom point
  1604. // 1 line start and its infinite zoom point
  1605.  
  1606. return [
  1607. first0.z >= first1.z ?
  1608. [first0.z, lineFirst0, getProgressedLine(lineFirst1, first0)] :
  1609. [first1.z, getProgressedLine(lineFirst0, first1), lineFirst1],
  1610.  
  1611. ...second0.z >= second1.z ?
  1612. [
  1613. [second1.z, getProgressedLine(lineFirst0, second1), lineSecond1],
  1614. [second0.z, lineSecond0, getProgressedLine(lineSecond1, second0)],
  1615. ] :
  1616. [
  1617. [second0.z, lineSecond0, getProgressedLine(lineFirst1, second0)],
  1618. [second1.z, getProgressedLine(lineSecond0, second1), lineSecond1],
  1619. ],
  1620. ];
  1621. };
  1622.  
  1623. const [pair0, pair1, pair2, doFlip = false] = (() => {
  1624. if (this.x >= 0 !== this.y >= 0) {
  1625. return isAbove(getLineFromPoints(second0, {x: 0.5, y: 0.5}), absPosition) ?
  1626. [...getPairings(false, false, true, false), true] :
  1627. getPairings(false, false, false, true);
  1628. }
  1629.  
  1630. return isAbove(getLineFromPoints(second1, {x: 0.5, y: 0.5}), absPosition) ?
  1631. getPairings(true, false, false, false) :
  1632. [...getPairings(false, true, false, false), true];
  1633. })();
  1634.  
  1635. const applyZoomPairSecond = ([z, ...pair], maxP = 1) => {
  1636. const p = getIntersectProgress(absPosition, ...pair, doFlip);
  1637.  
  1638. if (p >= 0 && p <= maxP) {
  1639. // I don't think the >= 1 check is necessary but best be safe
  1640. zoom.value = p >= 1 ? Number.MAX_SAFE_INTEGER : z / (1 - p);
  1641.  
  1642. return true;
  1643. }
  1644.  
  1645. return false;
  1646. };
  1647.  
  1648. if (
  1649. applyZoomPairSecond(pair2)
  1650. || applyZoomPairSecond(pair1, getProgress(pair1[0], pair2[0]))
  1651. || applyZoomPairSecond(pair0, getProgress(pair0[0], pair1[0]))
  1652. ) {
  1653. return;
  1654. }
  1655.  
  1656. zoom.value = pair0[0];
  1657. };
  1658. })();
  1659.  
  1660. const getZoomPoints = (() => {
  1661. const getPoints = (fitZoom, doFlip) => {
  1662. const getGenericRotated = (x, y, angle) => {
  1663. const radius = Math.sqrt(x * x + y * y);
  1664. const pointTheta = getTheta(0, 0, x, y) + angle;
  1665.  
  1666. return {
  1667. x: radius * Math.cos(pointTheta),
  1668. y: radius * Math.sin(pointTheta),
  1669. };
  1670. };
  1671.  
  1672. const getRotated = (xRaw, yRaw) => {
  1673. // Multiplying by video dimensions to have the axes' scales match the video's
  1674. // Using midPoint's raw values would only work if points moved elliptically around the centre of rotation
  1675. const rotated = getGenericRotated(xRaw * video.clientWidth, yRaw * video.clientHeight, (DEGREES[90] - rotation.value) % DEGREES[180]);
  1676.  
  1677. rotated.x /= video.clientWidth;
  1678. rotated.y /= video.clientHeight;
  1679.  
  1680. return rotated;
  1681. };
  1682.  
  1683. return [
  1684. {...getRotated(halfDimensions.viewport.width / video.clientWidth / fitZoom[0], 0), axis: doFlip ? 'y' : 'x'},
  1685. {...getRotated(0, halfDimensions.viewport.height / video.clientHeight / fitZoom[1]), axis: doFlip ? 'x' : 'y'},
  1686. ];
  1687. };
  1688.  
  1689. const getIntersection = (line, corner, middle) => {
  1690. const getIntersection = (line0, line1) => {
  1691. const a0 = line0[0].y - line0[1].y;
  1692. const b0 = line0[1].x - line0[0].x;
  1693. const c0 = line0[1].x * line0[0].y - line0[0].x * line0[1].y;
  1694.  
  1695. const a1 = line1[0].y - line1[1].y;
  1696. const b1 = line1[1].x - line1[0].x;
  1697. const c1 = line1[1].x * line1[0].y - line1[0].x * line1[1].y;
  1698.  
  1699. const d = a0 * b1 - b0 * a1;
  1700.  
  1701. return {
  1702. x: (c0 * b1 - b0 * c1) / d,
  1703. y: (a0 * c1 - c0 * a1) / d,
  1704. };
  1705. };
  1706.  
  1707. const {x, y} = getIntersection([{x: 0, y: 0}, middle], [line, corner]);
  1708. const progress = isThin ? (y - line.y) / (corner.y - line.y) : (x - line.x) / (corner.x - line.x);
  1709.  
  1710. return {x, y, z: line.z / (1 - progress), c: line.y};
  1711. };
  1712.  
  1713. const getIntersect = (yIntersect, corner, right, top) => {
  1714. const point0 = getIntersection(yIntersect, corner, right);
  1715. const point1 = getIntersection(yIntersect, corner, top);
  1716.  
  1717. const [point, vpEnd] = point0.z > point1.z ? [point0, {...right}] : [point1, {...top}];
  1718.  
  1719. if (Math.sign(point[vpEnd.axis]) !== Math.sign(vpEnd[vpEnd.axis])) {
  1720. vpEnd.x = -vpEnd.x;
  1721. vpEnd.y = -vpEnd.y;
  1722. }
  1723.  
  1724. return {...point, vpEnd};
  1725. };
  1726.  
  1727. // the angle from 0,0 to the center of the video edge angled towards the viewport's upper-right corner
  1728. const getQuadrantAngle = (isEvenQuadrant) => {
  1729. const angle = (rotation.value + DEGREES[360]) % DEGREES[90];
  1730.  
  1731. return isEvenQuadrant ? angle : DEGREES[90] - angle;
  1732. };
  1733.  
  1734. return () => {
  1735. const isEvenQuadrant = (Math.floor(rotation.value / DEGREES[90]) + 3) % 2 === 0;
  1736. const quadrantAngle = getQuadrantAngle(isEvenQuadrant);
  1737.  
  1738. const progress = quadrantAngle / DEGREES[90] * -2 + 1;
  1739. const progressAngles = {
  1740. base: Math.atan(progress * viewportRatio),
  1741. side: Math.atan(progress * viewportRatioInverse),
  1742. };
  1743. const progressCosines = {
  1744. base: Math.cos(progressAngles.base),
  1745. side: Math.cos(progressAngles.side),
  1746. };
  1747.  
  1748. const fitZoom = zoom.getVideoFit(true);
  1749. const points = getPoints(fitZoom, quadrantAngle >= DEGREES[45]);
  1750.  
  1751. const sideIntersection = getIntersect(
  1752. ((cornerAngle) => ({
  1753. x: 0,
  1754. y: (halfDimensions.video.height - halfDimensions.video.width * Math.tan(cornerAngle)) / video.clientHeight,
  1755. z: halfDimensions.viewport.width / (progressCosines.side * Math.abs(halfDimensions.video.width / Math.cos(cornerAngle))),
  1756. }))(quadrantAngle + progressAngles.side),
  1757. isEvenQuadrant ? {x: -0.5, y: 0.5} : {x: 0.5, y: 0.5},
  1758. ...points,
  1759. );
  1760.  
  1761. const baseIntersection = getIntersect(
  1762. ((cornerAngle) => ({
  1763. x: 0,
  1764. y: (halfDimensions.video.height - halfDimensions.video.width * Math.tan(cornerAngle)) / video.clientHeight,
  1765. z: halfDimensions.viewport.height / (progressCosines.base * Math.abs(halfDimensions.video.width / Math.cos(cornerAngle))),
  1766. }))(DEGREES[90] - quadrantAngle - progressAngles.base),
  1767. isEvenQuadrant ? {x: 0.5, y: 0.5} : {x: -0.5, y: 0.5},
  1768. ...points,
  1769. );
  1770.  
  1771. const [originSide, originBase] = fitZoom.map((z) => ({x: 0, y: 0, z}));
  1772.  
  1773. return isEvenQuadrant ?
  1774. [...[originSide, sideIntersection], ...[originBase, baseIntersection]] :
  1775. [...[originBase, baseIntersection], ...[originSide, sideIntersection]];
  1776. };
  1777. })();
  1778.  
  1779. let zoomPoints;
  1780.  
  1781. const getEnsureZoomPoints = (() => {
  1782. const updateLog = [];
  1783. let count = 0;
  1784.  
  1785. return () => {
  1786. const zoomPointCache = new DimensionCache(rotation);
  1787. const callbackCache = new Cache(zoom);
  1788. const id = count++;
  1789.  
  1790. return () => {
  1791. if (zoomPointCache.isStale()) {
  1792. updateLog.length = 0;
  1793.  
  1794. zoomPoints = getZoomPoints();
  1795. }
  1796.  
  1797. if (callbackCache.isStale() || !updateLog[id]) {
  1798. updateLog[id] = true;
  1799.  
  1800. return true;
  1801. }
  1802.  
  1803. return false;
  1804. };
  1805. };
  1806. })();
  1807.  
  1808. const handlers = {
  1809. [LIMITS.static]: ({custom: ratio}) => {
  1810. const bound = 0.5 + (ratio - 0.5) / zoom.value;
  1811.  
  1812. this.x = Math.max(-bound, Math.min(bound, this.x));
  1813. this.y = Math.max(-bound, Math.min(bound, this.y));
  1814. },
  1815. [LIMITS.fit]: (() => {
  1816. let boundApplyFrame;
  1817.  
  1818. const ensure = getEnsureZoomPoints();
  1819.  
  1820. return () => {
  1821. if (ensure()) {
  1822. boundApplyFrame = getBoundApplyFrame(...zoomPoints);
  1823. }
  1824.  
  1825. boundApplyFrame();
  1826. };
  1827. })(),
  1828. };
  1829.  
  1830. const snapHandlers = {
  1831. [LIMITS.fit]: (() => {
  1832. const ensure = getEnsureZoomPoints();
  1833.  
  1834. return () => {
  1835. ensure();
  1836.  
  1837. snapZoom(...zoomPoints);
  1838.  
  1839. zoom.constrain();
  1840. };
  1841. })(),
  1842. };
  1843.  
  1844. return (doZoom = false) => {
  1845. const {panLimit, snapPanLimit} = $config.get();
  1846.  
  1847. if (doZoom) {
  1848. snapHandlers[snapPanLimit.type]?.();
  1849. }
  1850.  
  1851. handlers[panLimit.type]?.(panLimit);
  1852.  
  1853. this.apply();
  1854. };
  1855. })();
  1856. }();
  1857.  
  1858. const crop = new function () {
  1859. this.top = this.right = this.bottom = this.left = 0;
  1860.  
  1861. this.getValues = () => ({top: this.top, right: this.right, bottom: this.bottom, left: this.left});
  1862.  
  1863. this.reveal = () => {
  1864. this.top = this.right = this.bottom = this.left = 0;
  1865.  
  1866. rule.remove();
  1867. };
  1868.  
  1869. this.reset = () => {
  1870. this.reveal();
  1871.  
  1872. actions.crop.reset();
  1873. };
  1874.  
  1875. const rule = new css.Toggleable();
  1876.  
  1877. this.apply = () => {
  1878. rule.remove();
  1879. rule.add(
  1880. `${SELECTOR_VIDEO}:not(.${this.CLASS_ABLE} *)`,
  1881. ['clip-path', `inset(${this.top * 100}% ${this.right * 100}% ${this.bottom * 100}% ${this.left * 100}%)`],
  1882. );
  1883.  
  1884. delete actions.reset.restore;
  1885.  
  1886. glow.handleViewChange();
  1887. glow.reset();
  1888. };
  1889.  
  1890. this.getDimensions = (width = video.clientWidth, height = video.clientHeight) => [
  1891. width * (1 - this.left - this.right),
  1892. height * (1 - this.top - this.bottom),
  1893. ];
  1894. }();
  1895.  
  1896. // FUNCTIONALITY
  1897.  
  1898. const glow = (() => {
  1899. const videoCanvas = new OffscreenCanvas(0, 0);
  1900. const videoCtx = videoCanvas.getContext('2d', {alpha: false});
  1901.  
  1902. const glowCanvas = document.createElement('canvas');
  1903. const glowCtx = glowCanvas.getContext('2d', {alpha: false});
  1904.  
  1905. glowCanvas.style.setProperty('position', 'absolute');
  1906.  
  1907. class Sector {
  1908. canvas = new OffscreenCanvas(0, 0);
  1909. ctx = this.canvas.getContext('2d', {alpha: false});
  1910.  
  1911. update(doFill) {
  1912. if (doFill) {
  1913. this.fill();
  1914. } else {
  1915. this.shift();
  1916. this.take();
  1917. }
  1918.  
  1919. this.giveEdge();
  1920.  
  1921. if (this.hasCorners) {
  1922. this.giveCorners();
  1923. }
  1924. }
  1925. }
  1926.  
  1927. class Side extends Sector {
  1928. setDimensions(doShiftRight, sWidth, sHeight, sx, sy, dx, dy, dWidth, dHeight) {
  1929. this.canvas.width = sWidth;
  1930. this.canvas.height = sHeight;
  1931.  
  1932. this.shift = this.ctx.drawImage.bind(this.ctx, this.canvas, doShiftRight ? 1 : -1, 0);
  1933.  
  1934. this.fill = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, 1, sHeight, 0, 0, sWidth, sHeight);
  1935. this.take = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, 1, sHeight, doShiftRight ? 0 : sWidth - 1, 0, 1, sHeight);
  1936.  
  1937. this.giveEdge = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);
  1938.  
  1939. if (dy === 0) {
  1940. this.hasCorners = false;
  1941.  
  1942. return;
  1943. }
  1944.  
  1945. this.hasCorners = true;
  1946.  
  1947. const giveCorner0 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, 1, dx, 0, dWidth, dy);
  1948. const giveCorner1 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, sHeight - 1, sWidth, 1, dx, dy + dHeight, dWidth, dy);
  1949.  
  1950. this.giveCorners = () => {
  1951. giveCorner0();
  1952. giveCorner1();
  1953. };
  1954. }
  1955. }
  1956.  
  1957. class Base extends Sector {
  1958. setDimensions(doShiftDown, sWidth, sHeight, sx, sy, dx, dy, dWidth, dHeight) {
  1959. this.canvas.width = sWidth;
  1960. this.canvas.height = sHeight;
  1961.  
  1962. this.shift = this.ctx.drawImage.bind(this.ctx, this.canvas, 0, doShiftDown ? 1 : -1);
  1963.  
  1964. this.fill = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, sWidth, 1, 0, 0, sWidth, sHeight);
  1965. this.take = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, sWidth, 1, 0, doShiftDown ? 0 : sHeight - 1, sWidth, 1);
  1966.  
  1967. this.giveEdge = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);
  1968.  
  1969. if (dx === 0) {
  1970. this.hasCorners = false;
  1971.  
  1972. return;
  1973. }
  1974.  
  1975. this.hasCorners = true;
  1976.  
  1977. const giveCorner0 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, 1, sHeight, 0, dy, dx, dHeight);
  1978. const giveCorner1 = glowCtx.drawImage.bind(glowCtx, this.canvas, sWidth - 1, 0, 1, sHeight, dx + dWidth, dy, dx, dHeight);
  1979.  
  1980. this.giveCorners = () => {
  1981. giveCorner0();
  1982. giveCorner1();
  1983. };
  1984. }
  1985.  
  1986. setClipPath(points) {
  1987. this.clipPath = new Path2D();
  1988.  
  1989. this.clipPath.moveTo(...points[0]);
  1990. this.clipPath.lineTo(...points[1]);
  1991. this.clipPath.lineTo(...points[2]);
  1992. this.clipPath.closePath();
  1993. }
  1994.  
  1995. update(doFill) {
  1996. glowCtx.save();
  1997.  
  1998. glowCtx.clip(this.clipPath);
  1999.  
  2000. super.update(doFill);
  2001.  
  2002. glowCtx.restore();
  2003. }
  2004. }
  2005.  
  2006. const components = {
  2007. left: new Side(),
  2008. right: new Side(),
  2009. top: new Base(),
  2010. bottom: new Base(),
  2011. };
  2012.  
  2013. const setComponentDimensions = (sampleCount, size, isInset, doFlip) => {
  2014. const [croppedWidth, croppedHeight] = crop.getDimensions();
  2015. const halfCanvas = {x: Math.ceil(glowCanvas.width / 2), y: Math.ceil(glowCanvas.height / 2)};
  2016. const halfVideo = {x: croppedWidth / 2, y: croppedHeight / 2};
  2017. const dWidth = Math.ceil(Math.min(halfVideo.x, size));
  2018. const dHeight = Math.ceil(Math.min(halfVideo.y, size));
  2019. const [dWidthScale, dHeightScale, sideWidth, sideHeight] = isInset ?
  2020. [0, 0, videoCanvas.width / croppedWidth * glowCanvas.width, videoCanvas.height / croppedHeight * glowCanvas.height] :
  2021. [halfCanvas.x - halfVideo.x, halfCanvas.y - halfVideo.y, croppedWidth, croppedHeight];
  2022.  
  2023. components.left.setDimensions(!doFlip, sampleCount, videoCanvas.height, 0, 0, 0, dHeightScale, dWidth, sideHeight);
  2024.  
  2025. components.right.setDimensions(doFlip, sampleCount, videoCanvas.height, videoCanvas.width - 1, 0, glowCanvas.width - dWidth, dHeightScale, dWidth, sideHeight);
  2026.  
  2027. components.top.setDimensions(!doFlip, videoCanvas.width, sampleCount, 0, 0, dWidthScale, 0, sideWidth, dHeight);
  2028. components.top.setClipPath([[0, 0], [halfCanvas.x, halfCanvas.y], [glowCanvas.width, 0]]);
  2029.  
  2030. components.bottom.setDimensions(doFlip, videoCanvas.width, sampleCount, 0, videoCanvas.height - 1, dWidthScale, glowCanvas.height - dHeight, sideWidth, dHeight);
  2031. components.bottom.setClipPath([[0, glowCanvas.height], [halfCanvas.x, halfCanvas.y], [glowCanvas.width, glowCanvas.height]]);
  2032. };
  2033.  
  2034. class Instance {
  2035. constructor() {
  2036. const {filter, sampleCount, size, end, doFlip} = $config.get().glow;
  2037.  
  2038. // Setup canvases
  2039.  
  2040. glowCanvas.style.setProperty('filter', filter);
  2041.  
  2042. [glowCanvas.width, glowCanvas.height] = crop.getDimensions().map((dimension) => dimension * end);
  2043.  
  2044. glowCanvas.style.setProperty('left', `${crop.left * 100 + (1 - end) * (1 - crop.left - crop.right) * 50}%`);
  2045. glowCanvas.style.setProperty('top', `${crop.top * 100 + (1 - end) * (1 - crop.top - crop.bottom) * 50}%`);
  2046.  
  2047. [videoCanvas.width, videoCanvas.height] = crop.getDimensions(video.videoWidth, video.videoHeight);
  2048.  
  2049. setComponentDimensions(sampleCount, size, end <= 1, doFlip);
  2050.  
  2051. this.update(true);
  2052. }
  2053.  
  2054. update(doFill = false) {
  2055. videoCtx.drawImage(
  2056. video,
  2057. crop.left * video.videoWidth,
  2058. crop.top * video.videoHeight,
  2059. video.videoWidth * (1 - crop.left - crop.right),
  2060. video.videoHeight * (1 - crop.top - crop.bottom),
  2061. 0,
  2062. 0,
  2063. videoCanvas.width,
  2064. videoCanvas.height,
  2065. );
  2066.  
  2067. components.left.update(doFill);
  2068. components.right.update(doFill);
  2069. components.top.update(doFill);
  2070. components.bottom.update(doFill);
  2071. }
  2072. }
  2073.  
  2074. return new function () {
  2075. const container = document.createElement('div');
  2076.  
  2077. container.style.display = 'none';
  2078.  
  2079. container.appendChild(glowCanvas);
  2080. containers.background.appendChild(container);
  2081.  
  2082. this.isHidden = false;
  2083.  
  2084. let instance, startCopyLoop, stopCopyLoop;
  2085.  
  2086. const play = () => {
  2087. if (!video.paused && !this.isHidden && !enabler.isHidingGlow) {
  2088. startCopyLoop?.();
  2089. }
  2090. };
  2091.  
  2092. const fill = () => {
  2093. if (!this.isHidden) {
  2094. instance.update(true);
  2095. }
  2096. };
  2097.  
  2098. const handleVisibilityChange = () => {
  2099. if (document.hidden) {
  2100. stopCopyLoop();
  2101. } else {
  2102. play();
  2103. }
  2104. };
  2105.  
  2106. this.handleSizeChange = () => {
  2107. instance = new Instance();
  2108. };
  2109.  
  2110. // set up pausing if glow isn't visible
  2111. this.handleViewChange = (() => {
  2112. const cache = new Cache(rotation, zoom);
  2113.  
  2114. let corners;
  2115.  
  2116. return (doForce = false) => {
  2117. if (doForce || cache.isStale()) {
  2118. const width = halfDimensions.viewport.width / zoom.value;
  2119. const height = halfDimensions.viewport.height / zoom.value;
  2120. const radius = Math.sqrt(width * width + height * height);
  2121. corners = getRotatedCorners(radius, viewportTheta);
  2122. }
  2123.  
  2124. const videoX = position.x * video.clientWidth;
  2125. const videoY = position.y * video.clientHeight;
  2126.  
  2127. for (const corner of corners) {
  2128. if (
  2129. // unpause if the viewport extends more than 1 pixel beyond a video edge
  2130. videoX + corner.x > (0.5 - crop.right) * video.clientWidth + 1
  2131. || videoX - corner.x < (crop.left - 0.5) * video.clientWidth - 1
  2132. || videoY + corner.y > (0.5 - crop.top) * video.clientHeight + 1
  2133. || videoY - corner.y < (crop.bottom - 0.5) * video.clientHeight - 1
  2134. ) {
  2135. // fill if newly visible
  2136. if (this.isHidden) {
  2137. instance?.update(true);
  2138. }
  2139.  
  2140. this.isHidden = false;
  2141.  
  2142. glowCanvas.style.removeProperty('visibility');
  2143.  
  2144. play();
  2145.  
  2146. return;
  2147. }
  2148. }
  2149.  
  2150. this.isHidden = true;
  2151.  
  2152. glowCanvas.style.visibility = 'hidden';
  2153.  
  2154. stopCopyLoop?.();
  2155. };
  2156. })();
  2157.  
  2158. const loop = {};
  2159.  
  2160. this.start = () => {
  2161. const config = $config.get().glow;
  2162.  
  2163. if (!config) {
  2164. return;
  2165. }
  2166.  
  2167. if (!enabler.isHidingGlow) {
  2168. container.style.removeProperty('display');
  2169. }
  2170.  
  2171. // todo handle this?
  2172. if (crop.left + crop.right >= 1 || crop.top + crop.bottom >= 1) {
  2173. return;
  2174. }
  2175.  
  2176. let loopId = -1;
  2177.  
  2178. if (loop.interval !== config.interval || loop.fps !== config.fps) {
  2179. loop.interval = config.interval;
  2180. loop.fps = config.fps;
  2181. loop.wasSlow = false;
  2182. loop.throttleCount = 0;
  2183. }
  2184.  
  2185. stopCopyLoop = () => ++loopId;
  2186.  
  2187. instance = new Instance();
  2188.  
  2189. startCopyLoop = async () => {
  2190. const id = ++loopId;
  2191.  
  2192. await new Promise((resolve) => {
  2193. window.setTimeout(resolve, config.interval);
  2194. });
  2195.  
  2196. while (id === loopId) {
  2197. const startTime = Date.now();
  2198.  
  2199. instance.update();
  2200.  
  2201. const delay = loop.interval - (Date.now() - startTime);
  2202.  
  2203. if (delay <= 0) {
  2204. if (loop.wasSlow) {
  2205. loop.interval = 1000 / (loop.fps - ++loop.throttleCount);
  2206. }
  2207.  
  2208. loop.wasSlow = !loop.wasSlow;
  2209.  
  2210. continue;
  2211. }
  2212.  
  2213. if (delay > 2 && loop.throttleCount > 0) {
  2214. console.warn(`[${GM.info.script.name}] Glow update frequency reduced from ${loop.fps} hertz to ${loop.fps - loop.throttleCount} hertz due to poor performance.`);
  2215.  
  2216. loop.fps -= loop.throttleCount;
  2217.  
  2218. loop.throttleCount = 0;
  2219. }
  2220.  
  2221. loop.wasSlow = false;
  2222.  
  2223. await new Promise((resolve) => {
  2224. window.setTimeout(resolve, delay);
  2225. });
  2226. }
  2227. };
  2228.  
  2229. play();
  2230.  
  2231. video.addEventListener('pause', stopCopyLoop);
  2232. video.addEventListener('play', play);
  2233. video.addEventListener('seeked', fill);
  2234.  
  2235. document.addEventListener('visibilitychange', handleVisibilityChange);
  2236. };
  2237.  
  2238. const priorCrop = {};
  2239.  
  2240. this.hide = () => {
  2241. Object.assign(priorCrop, crop);
  2242.  
  2243. stopCopyLoop?.();
  2244.  
  2245. container.style.display = 'none';
  2246. };
  2247.  
  2248. this.show = () => {
  2249. if (Object.entries(priorCrop).some(([edge, value]) => crop[edge] !== value)) {
  2250. this.reset();
  2251. } else {
  2252. play();
  2253. }
  2254.  
  2255. container.style.removeProperty('display');
  2256. };
  2257.  
  2258. this.stop = () => {
  2259. this.hide();
  2260.  
  2261. video.removeEventListener('pause', stopCopyLoop);
  2262. video.removeEventListener('play', play);
  2263. video.removeEventListener('seeked', fill);
  2264.  
  2265. document.removeEventListener('visibilitychange', handleVisibilityChange);
  2266.  
  2267. startCopyLoop = undefined;
  2268. stopCopyLoop = undefined;
  2269. };
  2270.  
  2271. this.reset = () => {
  2272. this.stop();
  2273.  
  2274. this.start();
  2275. };
  2276. }();
  2277. })();
  2278.  
  2279. const peek = (stop = false) => {
  2280. const prior = {
  2281. zoom: zoom.value,
  2282. rotation: rotation.value,
  2283. crop: crop.getValues(),
  2284. position: position.getValues(),
  2285. };
  2286.  
  2287. position.reset();
  2288. rotation.reset();
  2289. zoom.reset();
  2290. crop.reset();
  2291.  
  2292. glow[stop ? 'stop' : 'reset']();
  2293.  
  2294. return () => {
  2295. zoom.value = prior.zoom;
  2296. rotation.value = prior.rotation;
  2297. Object.assign(position, prior.position);
  2298. Object.assign(crop, prior.crop);
  2299.  
  2300. actions.crop.set(prior.crop);
  2301.  
  2302. position.apply();
  2303. rotation.apply();
  2304. zoom.apply();
  2305. crop.apply();
  2306. };
  2307. };
  2308.  
  2309. const actions = (() => {
  2310. const drag = (event, clickCallback, moveCallback, target = video) => new Promise((resolve) => {
  2311. event.stopImmediatePropagation();
  2312. event.preventDefault();
  2313.  
  2314. // window blur events don't fire if devtools is open
  2315. stopDrag?.();
  2316.  
  2317. target.setPointerCapture(event.pointerId);
  2318.  
  2319. css.tag(enabler.CLASS_DRAGGING);
  2320.  
  2321. const cancel = (event) => {
  2322. event.stopImmediatePropagation();
  2323. event.preventDefault();
  2324. };
  2325.  
  2326. document.addEventListener('click', cancel, true);
  2327. document.addEventListener('dblclick', cancel, true);
  2328.  
  2329. const clickDisallowListener = ({clientX, clientY}) => {
  2330. const {clickCutoff} = $config.get();
  2331. const distance = Math.abs(event.clientX - clientX) + Math.abs(event.clientY - clientY);
  2332.  
  2333. if (distance >= clickCutoff) {
  2334. target.removeEventListener('pointermove', clickDisallowListener);
  2335. target.removeEventListener('pointerup', clickCallback);
  2336. }
  2337. };
  2338.  
  2339. if (clickCallback) {
  2340. target.addEventListener('pointermove', clickDisallowListener);
  2341. target.addEventListener('pointerup', clickCallback, {once: true});
  2342. }
  2343.  
  2344. target.addEventListener('pointermove', moveCallback);
  2345.  
  2346. stopDrag = () => {
  2347. css.tag(enabler.CLASS_DRAGGING, false);
  2348.  
  2349. target.removeEventListener('pointermove', moveCallback);
  2350.  
  2351. if (clickCallback) {
  2352. target.removeEventListener('pointermove', clickDisallowListener);
  2353. target.removeEventListener('pointerup', clickCallback);
  2354. }
  2355.  
  2356. // delay removing listeners for events that happen after pointerup
  2357. window.setTimeout(() => {
  2358. document.removeEventListener('dblclick', cancel, true);
  2359. document.removeEventListener('click', cancel, true);
  2360. }, 0);
  2361.  
  2362. window.removeEventListener('blur', stopDrag);
  2363. target.removeEventListener('pointerup', stopDrag);
  2364.  
  2365. target.releasePointerCapture(event.pointerId);
  2366.  
  2367. stopDrag = undefined;
  2368.  
  2369. enabler.handleChange();
  2370.  
  2371. resolve();
  2372. };
  2373.  
  2374. window.addEventListener('blur', stopDrag);
  2375. target.addEventListener('pointerup', stopDrag);
  2376. });
  2377.  
  2378. const getOnScroll = (() => {
  2379. // https://stackoverflow.com/a/30134826
  2380. const multipliers = [1, 40, 800];
  2381.  
  2382. return (callback) => (event) => {
  2383. event.stopImmediatePropagation();
  2384. event.preventDefault();
  2385.  
  2386. if (event.deltaY !== 0) {
  2387. callback(event.deltaY * multipliers[event.deltaMode]);
  2388. }
  2389. };
  2390. })();
  2391.  
  2392. const addListeners = ({onMouseDown, onRightClick, onScroll}, doAdd = true) => {
  2393. const property = `${doAdd ? 'add' : 'remove'}EventListener`;
  2394.  
  2395. altTarget[property]('pointerdown', onMouseDown);
  2396. altTarget[property]('contextmenu', onRightClick, true);
  2397. altTarget[property]('wheel', onScroll);
  2398. };
  2399.  
  2400. return {
  2401. crop: new function () {
  2402. let top = 0, right = 0, bottom = 0, left = 0, handle;
  2403.  
  2404. const values = {};
  2405.  
  2406. Object.defineProperty(values, 'top', {get: () => top, set: (value) => top = value});
  2407. Object.defineProperty(values, 'right', {get: () => right, set: (value) => right = value});
  2408. Object.defineProperty(values, 'bottom', {get: () => bottom, set: (value) => bottom = value});
  2409. Object.defineProperty(values, 'left', {get: () => left, set: (value) => left = value});
  2410.  
  2411. class Button {
  2412. // allowance for rounding errors
  2413. static ALLOWANCE_HANDLE = 0.0001;
  2414.  
  2415. static CLASS_HANDLE = 'viewfind-crop-handle';
  2416. static CLASS_EDGES = {
  2417. left: 'viewfind-crop-left',
  2418. top: 'viewfind-crop-top',
  2419. right: 'viewfind-crop-right',
  2420. bottom: 'viewfind-crop-bottom',
  2421. };
  2422.  
  2423. static OPPOSITES = {
  2424. left: 'right',
  2425. right: 'left',
  2426.  
  2427. top: 'bottom',
  2428. bottom: 'top',
  2429. };
  2430.  
  2431. callbacks = [];
  2432.  
  2433. element = document.createElement('div');
  2434.  
  2435. constructor(...edges) {
  2436. this.edges = edges;
  2437.  
  2438. this.isHandle = true;
  2439.  
  2440. this.element.style.position = 'absolute';
  2441. this.element.style.pointerEvents = 'all';
  2442.  
  2443. for (const edge of edges) {
  2444. this.element.style[edge] = '0';
  2445.  
  2446. this.element.classList.add(Button.CLASS_EDGES[edge]);
  2447.  
  2448. this.element.style.setProperty(`border-${Button.OPPOSITES[edge]}-width`, '1px');
  2449. }
  2450.  
  2451. this.element.addEventListener('contextmenu', (event) => {
  2452. event.stopPropagation();
  2453. event.preventDefault();
  2454.  
  2455. this.reset(false);
  2456. });
  2457.  
  2458. this.element.addEventListener('pointerdown', (() => {
  2459. const clickListener = ({offsetX, offsetY, target}) => {
  2460. this.set({
  2461. width: (this.edges.includes('left') ? offsetX : target.clientWidth - offsetX) / video.clientWidth,
  2462. height: (this.edges.includes('top') ? offsetY : target.clientHeight - offsetY) / video.clientHeight,
  2463. }, false);
  2464. };
  2465.  
  2466. const getDragListener = (event, target) => {
  2467. const getWidth = (() => {
  2468. if (this.edges.includes('left')) {
  2469. const position = this.element.clientWidth - event.offsetX;
  2470.  
  2471. return ({offsetX}) => offsetX + position;
  2472. }
  2473.  
  2474. const position = target.offsetWidth + event.offsetX;
  2475.  
  2476. return ({offsetX}) => position - offsetX;
  2477. })();
  2478.  
  2479. const getHeight = (() => {
  2480. if (this.edges.includes('top')) {
  2481. const position = this.element.clientHeight - event.offsetY;
  2482.  
  2483. return ({offsetY}) => offsetY + position;
  2484. }
  2485.  
  2486. const position = target.offsetHeight + event.offsetY;
  2487.  
  2488. return ({offsetY}) => position - offsetY;
  2489. })();
  2490.  
  2491. return (event) => {
  2492. this.set({
  2493. width: getWidth(event) / video.clientWidth,
  2494. height: getHeight(event) / video.clientHeight,
  2495. });
  2496. };
  2497. };
  2498.  
  2499. return async (event) => {
  2500. if (event.buttons === 1) {
  2501. const target = this.element.parentElement;
  2502.  
  2503. if (this.isHandle) {
  2504. this.setPanel();
  2505. }
  2506.  
  2507. await drag(event, clickListener, getDragListener(event, target), target);
  2508.  
  2509. this.updateCounterpart();
  2510. }
  2511. };
  2512. })());
  2513. }
  2514.  
  2515. notify() {
  2516. for (const callback of this.callbacks) {
  2517. callback();
  2518. }
  2519. }
  2520.  
  2521. set isHandle(value) {
  2522. this._isHandle = value;
  2523.  
  2524. this.element.classList[value ? 'add' : 'remove'](Button.CLASS_HANDLE);
  2525. }
  2526.  
  2527. get isHandle() {
  2528. return this._isHandle;
  2529. }
  2530.  
  2531. reset() {
  2532. this.isHandle = true;
  2533.  
  2534. for (const edge of this.edges) {
  2535. values[edge] = 0;
  2536. }
  2537. }
  2538. }
  2539.  
  2540. class EdgeButton extends Button {
  2541. constructor(edge) {
  2542. super(edge);
  2543.  
  2544. this.edge = edge;
  2545. }
  2546.  
  2547. updateCounterpart() {
  2548. if (this.counterpart.isHandle) {
  2549. this.counterpart.setHandle();
  2550. }
  2551. }
  2552.  
  2553. setCrop(value = 0) {
  2554. values[this.edge] = value;
  2555. }
  2556.  
  2557. setPanel() {
  2558. this.isHandle = false;
  2559.  
  2560. this.setCrop(handle);
  2561.  
  2562. this.setHandle();
  2563. }
  2564. }
  2565.  
  2566. class SideButton extends EdgeButton {
  2567. flow() {
  2568. let size = 1;
  2569.  
  2570. if (top <= Button.ALLOWANCE_HANDLE) {
  2571. size -= handle;
  2572.  
  2573. this.element.style.top = `${handle * 100}%`;
  2574. } else {
  2575. size -= top;
  2576.  
  2577. this.element.style.top = `${top * 100}%`;
  2578. }
  2579.  
  2580. if (bottom <= Button.ALLOWANCE_HANDLE) {
  2581. size -= handle;
  2582. } else {
  2583. size -= bottom;
  2584. }
  2585.  
  2586. this.element.style.height = `${Math.max(0, size * 100)}%`;
  2587. }
  2588.  
  2589. setBounds(counterpart, components) {
  2590. this.counterpart = components[counterpart];
  2591.  
  2592. components.top.callbacks.push(() => {
  2593. this.flow();
  2594. });
  2595.  
  2596. components.bottom.callbacks.push(() => {
  2597. this.flow();
  2598. });
  2599. }
  2600.  
  2601. setHandle(doNotify = true) {
  2602. this.element.style.width = `${Math.min(1 - values[this.counterpart.edge], handle) * 100}%`;
  2603.  
  2604. if (doNotify) {
  2605. this.notify();
  2606. }
  2607. }
  2608.  
  2609. set({width}, doUpdateCounterpart = true) {
  2610. if (this.isHandle !== (this.isHandle = width <= Button.ALLOWANCE_HANDLE)) {
  2611. this.flow();
  2612. }
  2613.  
  2614. if (doUpdateCounterpart) {
  2615. this.updateCounterpart();
  2616. }
  2617.  
  2618. if (this.isHandle) {
  2619. this.setCrop();
  2620.  
  2621. this.setHandle();
  2622.  
  2623. return;
  2624. }
  2625.  
  2626. const size = Math.min(1 - values[this.counterpart.edge], width);
  2627.  
  2628. this.setCrop(size);
  2629.  
  2630. this.element.style.width = `${size * 100}%`;
  2631.  
  2632. this.notify();
  2633. }
  2634.  
  2635. reset(isGeneral = true) {
  2636. super.reset();
  2637.  
  2638. if (isGeneral) {
  2639. this.element.style.top = `${handle * 100}%`;
  2640. this.element.style.height = `${(0.5 - handle) * 200}%`;
  2641. this.element.style.width = `${handle * 100}%`;
  2642.  
  2643. return;
  2644. }
  2645.  
  2646. this.flow();
  2647.  
  2648. this.setHandle();
  2649.  
  2650. this.updateCounterpart();
  2651. }
  2652. }
  2653.  
  2654. class BaseButton extends EdgeButton {
  2655. flow() {
  2656. let size = 1;
  2657.  
  2658. if (left <= Button.ALLOWANCE_HANDLE) {
  2659. size -= handle;
  2660.  
  2661. this.element.style.left = `${handle * 100}%`;
  2662. } else {
  2663. size -= left;
  2664.  
  2665. this.element.style.left = `${left * 100}%`;
  2666. }
  2667.  
  2668. if (right <= Button.ALLOWANCE_HANDLE) {
  2669. size -= handle;
  2670. } else {
  2671. size -= right;
  2672. }
  2673.  
  2674. this.element.style.width = `${Math.max(0, size) * 100}%`;
  2675. }
  2676.  
  2677. setBounds(counterpart, components) {
  2678. this.counterpart = components[counterpart];
  2679.  
  2680. components.left.callbacks.push(() => {
  2681. this.flow();
  2682. });
  2683.  
  2684. components.right.callbacks.push(() => {
  2685. this.flow();
  2686. });
  2687. }
  2688.  
  2689. setHandle(doNotify = true) {
  2690. this.element.style.height = `${Math.min(1 - values[this.counterpart.edge], handle) * 100}%`;
  2691.  
  2692. if (doNotify) {
  2693. this.notify();
  2694. }
  2695. }
  2696.  
  2697. set({height}, doUpdateCounterpart = false) {
  2698. if (this.isHandle !== (this.isHandle = height <= Button.ALLOWANCE_HANDLE)) {
  2699. this.flow();
  2700. }
  2701.  
  2702. if (doUpdateCounterpart) {
  2703. this.updateCounterpart();
  2704. }
  2705.  
  2706. if (this.isHandle) {
  2707. this.setCrop();
  2708.  
  2709. this.setHandle();
  2710.  
  2711. return;
  2712. }
  2713.  
  2714. const size = Math.min(1 - values[this.counterpart.edge], height);
  2715.  
  2716. this.setCrop(size);
  2717.  
  2718. this.element.style.height = `${size * 100}%`;
  2719.  
  2720. this.notify();
  2721. }
  2722.  
  2723. reset(isGeneral = true) {
  2724. super.reset();
  2725.  
  2726. if (isGeneral) {
  2727. this.element.style.left = `${handle * 100}%`;
  2728. this.element.style.width = `${(0.5 - handle) * 200}%`;
  2729. this.element.style.height = `${handle * 100}%`;
  2730.  
  2731. return;
  2732. }
  2733.  
  2734. this.flow();
  2735.  
  2736. this.setHandle();
  2737.  
  2738. this.updateCounterpart();
  2739. }
  2740. }
  2741.  
  2742. class CornerButton extends Button {
  2743. static CLASS_NAME = 'viewfind-crop-corner';
  2744.  
  2745. constructor(sectors, ...edges) {
  2746. super(...edges);
  2747.  
  2748. this.element.classList.add(CornerButton.CLASS_NAME);
  2749.  
  2750. this.sectors = sectors;
  2751.  
  2752. for (const sector of sectors) {
  2753. sector.callbacks.push(this.flow.bind(this));
  2754. }
  2755. }
  2756.  
  2757. flow() {
  2758. let isHandle = true;
  2759.  
  2760. if (this.sectors[0].isHandle) {
  2761. this.element.style.width = `${Math.min(1 - values[this.sectors[0].counterpart.edge], handle) * 100}%`;
  2762. } else {
  2763. this.element.style.width = `${values[this.edges[0]] * 100}%`;
  2764.  
  2765. isHandle = false;
  2766. }
  2767.  
  2768. if (this.sectors[1].isHandle) {
  2769. this.element.style.height = `${Math.min(1 - values[this.sectors[1].counterpart.edge], handle) * 100}%`;
  2770. } else {
  2771. this.element.style.height = `${values[this.edges[1]] * 100}%`;
  2772.  
  2773. isHandle = false;
  2774. }
  2775.  
  2776. this.isHandle = isHandle;
  2777. }
  2778.  
  2779. updateCounterpart() {
  2780. for (const sector of this.sectors) {
  2781. sector.updateCounterpart();
  2782. }
  2783. }
  2784.  
  2785. set(size) {
  2786. for (const sector of this.sectors) {
  2787. sector.set(size);
  2788. }
  2789. }
  2790.  
  2791. reset(isGeneral = true) {
  2792. this.isHandle = true;
  2793.  
  2794. this.element.style.width = `${handle * 100}%`;
  2795. this.element.style.height = `${handle * 100}%`;
  2796.  
  2797. if (isGeneral) {
  2798. return;
  2799. }
  2800.  
  2801. for (const sector of this.sectors) {
  2802. sector.reset(false);
  2803. }
  2804. }
  2805.  
  2806. setPanel() {
  2807. for (const sector of this.sectors) {
  2808. sector.setPanel();
  2809. }
  2810. }
  2811. }
  2812.  
  2813. this.CODE = 'crop';
  2814.  
  2815. this.CLASS_ABLE = 'viewfind-action-able-crop';
  2816.  
  2817. const container = document.createElement('div');
  2818.  
  2819. // todo ditch the containers object
  2820. container.style.width = container.style.height = 'inherit';
  2821.  
  2822. containers.foreground.append(container);
  2823.  
  2824. this.reset = () => {
  2825. for (const component of Object.values(this.components)) {
  2826. component.reset(true);
  2827. }
  2828. };
  2829.  
  2830. this.onRightClick = (event) => {
  2831. if (event.target.parentElement.id === container.id) {
  2832. return;
  2833. }
  2834.  
  2835. event.stopPropagation();
  2836. event.preventDefault();
  2837.  
  2838. if (stopDrag) {
  2839. return;
  2840. }
  2841.  
  2842. this.reset();
  2843. };
  2844.  
  2845. this.onScroll = getOnScroll((distance) => {
  2846. const increment = distance * $config.get().speeds.crop / zoom.value;
  2847.  
  2848. this.components.top.set({height: top + Math.min((1 - top - bottom) / 2, increment)});
  2849. this.components.left.set({width: left + Math.min((1 - left - right) / 2, increment)});
  2850.  
  2851. this.components.bottom.set({height: bottom + increment});
  2852. this.components.right.set({width: right + increment});
  2853. });
  2854.  
  2855. this.onMouseDown = (() => {
  2856. const getDragListener = () => {
  2857. const multiplier = $config.get().multipliers.crop;
  2858.  
  2859. const setX = ((right, left, change) => {
  2860. const clamped = Math.max(-left, Math.min(right, change * multiplier / video.clientWidth));
  2861.  
  2862. this.components.left.set({width: left + clamped});
  2863. this.components.right.set({width: right - clamped});
  2864. }).bind(undefined, right, left);
  2865.  
  2866. const setY = ((top, bottom, change) => {
  2867. const clamped = Math.max(-top, Math.min(bottom, change * multiplier / video.clientHeight));
  2868.  
  2869. this.components.top.set({height: top + clamped});
  2870.  
  2871. this.components.bottom.set({height: bottom - clamped});
  2872. }).bind(undefined, top, bottom);
  2873.  
  2874. let priorEvent;
  2875.  
  2876. return ({offsetX, offsetY}) => {
  2877. if (!priorEvent) {
  2878. priorEvent = {offsetX, offsetY};
  2879.  
  2880. return;
  2881. }
  2882.  
  2883. setX(offsetX - priorEvent.offsetX);
  2884. setY(offsetY - priorEvent.offsetY);
  2885. };
  2886. };
  2887.  
  2888. const clickListener = () => {
  2889. zoom.value = zoom.getFit((1 - left - right) * halfDimensions.video.width, (1 - top - bottom) * halfDimensions.video.height);
  2890.  
  2891. zoom.constrain();
  2892.  
  2893. position.x = (left - right) / 2;
  2894. position.y = (bottom - top) / 2;
  2895.  
  2896. position.constrain();
  2897. };
  2898.  
  2899. return (event) => {
  2900. if (event.buttons === 1) {
  2901. drag(event, clickListener, getDragListener(), container);
  2902. }
  2903. };
  2904. })();
  2905.  
  2906. this.components = {
  2907. top: new BaseButton('top'),
  2908. right: new SideButton('right'),
  2909. bottom: new BaseButton('bottom'),
  2910. left: new SideButton('left'),
  2911. };
  2912.  
  2913. this.components.top.setBounds('bottom', this.components);
  2914. this.components.right.setBounds('left', this.components);
  2915. this.components.bottom.setBounds('top', this.components);
  2916. this.components.left.setBounds('right', this.components);
  2917.  
  2918. this.components.topLeft = new CornerButton([this.components.left, this.components.top], 'left', 'top');
  2919. this.components.topRight = new CornerButton([this.components.right, this.components.top], 'right', 'top');
  2920. this.components.bottomLeft = new CornerButton([this.components.left, this.components.bottom], 'left', 'bottom');
  2921. this.components.bottomRight = new CornerButton([this.components.right, this.components.bottom], 'right', 'bottom');
  2922.  
  2923. container.append(...Object.values(this.components).map(({element}) => element));
  2924.  
  2925. this.set = ({top, right, bottom, left}) => {
  2926. this.components.top.set({height: top});
  2927. this.components.right.set({width: right});
  2928. this.components.bottom.set({height: bottom});
  2929. this.components.left.set({width: left});
  2930. };
  2931.  
  2932. this.onInactive = () => {
  2933. addListeners(this, false);
  2934.  
  2935. if (crop.left === left && crop.top === top && crop.right === right && crop.bottom === bottom) {
  2936. return;
  2937. }
  2938.  
  2939. crop.left = left;
  2940. crop.top = top;
  2941. crop.right = right;
  2942. crop.bottom = bottom;
  2943.  
  2944. crop.apply();
  2945. };
  2946.  
  2947. this.onActive = () => {
  2948. const config = $config.get().crop;
  2949.  
  2950. handle = config.handle / Math.max(zoom.value, 1);
  2951.  
  2952. for (const component of [this.components.top, this.components.bottom, this.components.left, this.components.right]) {
  2953. if (component.isHandle) {
  2954. component.setHandle();
  2955. }
  2956. }
  2957.  
  2958. crop.reveal();
  2959.  
  2960. addListeners(this);
  2961.  
  2962. if (!enabler.isHidingGlow) {
  2963. glow.handleViewChange();
  2964.  
  2965. glow.reset();
  2966. }
  2967. };
  2968.  
  2969. const draggingSelector = css.getSelector(enabler.CLASS_DRAGGING);
  2970.  
  2971. this.updateConfig = (() => {
  2972. const rule = new css.Toggleable();
  2973.  
  2974. return () => {
  2975. // set handle size
  2976. for (const button of [this.components.left, this.components.top, this.components.right, this.components.bottom]) {
  2977. if (button.isHandle) {
  2978. button.setHandle();
  2979. }
  2980. }
  2981.  
  2982. rule.remove();
  2983.  
  2984. const {colour} = $config.get().crop;
  2985. const {id} = container;
  2986.  
  2987. rule.add(`#${id}>:hover.${Button.CLASS_HANDLE},#${id}>:not(.${Button.CLASS_HANDLE})`, ['background-color', colour.fill]);
  2988. rule.add(`#${id}>*`, ['border-color', colour.border]);
  2989. rule.add(`#${id}:not(${draggingSelector} *)>:not(:hover)`, ['filter', `drop-shadow(${colour.shadow} 0 0 1px)`]);
  2990. };
  2991. })();
  2992.  
  2993. $config.ready.then(() => {
  2994. this.updateConfig();
  2995. });
  2996.  
  2997. container.id = 'viewfind-crop-container';
  2998.  
  2999. (() => {
  3000. const {id} = container;
  3001.  
  3002. css.add(`${css.getSelector(enabler.CLASS_DRAGGING)} #${id}`, ['cursor', 'grabbing']);
  3003. css.add(`${css.getSelector(enabler.CLASS_ABLE)} #${id}`, ['cursor', 'grab']);
  3004. css.add(`#${id}>:not(${draggingSelector} .${Button.CLASS_HANDLE})`, ['border-style', 'solid']);
  3005. css.add(`${draggingSelector} #${id}>.${Button.CLASS_HANDLE}`, ['filter', 'none']);
  3006.  
  3007. for (const [side, sideClass] of Object.entries(Button.CLASS_EDGES)) {
  3008. css.add(
  3009. `${draggingSelector} #${id}>.${sideClass}.${Button.CLASS_HANDLE}~.${sideClass}.${CornerButton.CLASS_NAME}`,
  3010. [`border-${CornerButton.OPPOSITES[side]}-style`, 'none'],
  3011. ['filter', 'none'],
  3012. );
  3013.  
  3014. // in fullscreen, 16:9 videos get an offsetLeft of 1px on my 16:9 monitor
  3015. // I'm extending buttons by 1px so that they reach the edge of screens like mine at default zoom
  3016. css.add(`#${id}>.${sideClass}`, [`margin-${side}`, '-1px'], [`padding-${side}`, '1px']);
  3017. }
  3018.  
  3019. css.add(`#${id}:not(.${this.CLASS_ABLE} *)`, ['display', 'none']);
  3020. })();
  3021. }(),
  3022.  
  3023. pan: new function () {
  3024. this.CODE = 'pan';
  3025.  
  3026. this.CLASS_ABLE = 'viewfind-action-able-pan';
  3027.  
  3028. this.onActive = () => {
  3029. this.updateCrosshair();
  3030.  
  3031. addListeners(this);
  3032. };
  3033.  
  3034. this.onInactive = () => {
  3035. addListeners(this, false);
  3036. };
  3037.  
  3038. this.updateCrosshair = (() => {
  3039. const getRoundedString = (number, decimal = 2) => {
  3040. const raised = `${Math.round(number * Math.pow(10, decimal))}`.padStart(decimal + 1, '0');
  3041.  
  3042. return `${raised.substr(0, raised.length - decimal)}.${raised.substr(raised.length - decimal)}`;
  3043. };
  3044.  
  3045. const getSigned = (ratio) => {
  3046. const percent = Math.round(ratio * 100);
  3047.  
  3048. if (percent <= 0) {
  3049. return `${percent}`;
  3050. }
  3051.  
  3052. return `+${percent}`;
  3053. };
  3054.  
  3055. return () => {
  3056. crosshair.text.innerText = `${getRoundedString(zoom.value)}×\n${getSigned(position.x)}%\n${getSigned(position.y)}%`;
  3057. };
  3058. })();
  3059.  
  3060. this.onScroll = getOnScroll((distance) => {
  3061. const increment = distance * $config.get().speeds.zoom;
  3062.  
  3063. if (increment > 0) {
  3064. zoom.value *= 1 + increment;
  3065. } else {
  3066. zoom.value /= 1 - increment;
  3067. }
  3068.  
  3069. zoom.constrain();
  3070.  
  3071. position.constrain();
  3072.  
  3073. this.updateCrosshair();
  3074. });
  3075.  
  3076. this.onRightClick = (event) => {
  3077. event.stopImmediatePropagation();
  3078. event.preventDefault();
  3079.  
  3080. if (stopDrag) {
  3081. return;
  3082. }
  3083.  
  3084. position.x = position.y = 0;
  3085. zoom.value = 1;
  3086.  
  3087. position.apply();
  3088.  
  3089. zoom.constrain();
  3090.  
  3091. this.updateCrosshair();
  3092. };
  3093.  
  3094. this.onMouseDown = (() => {
  3095. const getDragListener = () => {
  3096. const {multipliers} = $config.get();
  3097.  
  3098. let priorEvent;
  3099.  
  3100. const change = {x: 0, y: 0};
  3101.  
  3102. return ({offsetX, offsetY}) => {
  3103. if (priorEvent) {
  3104. change.x = (priorEvent.offsetX + change.x - offsetX) * multipliers.pan;
  3105. change.y = (priorEvent.offsetY - change.y - offsetY) * -multipliers.pan;
  3106.  
  3107. position.x += change.x / video.clientWidth;
  3108. position.y += change.y / video.clientHeight;
  3109.  
  3110. position.constrain();
  3111.  
  3112. this.updateCrosshair();
  3113. }
  3114.  
  3115. // events in firefox seem to lose their data after finishing propagation
  3116. // so assigning the whole event doesn't work
  3117. priorEvent = {offsetX, offsetY};
  3118. };
  3119. };
  3120.  
  3121. const clickListener = (event) => {
  3122. position.x = event.offsetX / video.clientWidth - 0.5;
  3123. // Y increases moving down the page
  3124. // I flip that to make trigonometry easier
  3125. position.y = -event.offsetY / video.clientHeight + 0.5;
  3126.  
  3127. position.constrain(true);
  3128.  
  3129. this.updateCrosshair();
  3130. };
  3131.  
  3132. return (event) => {
  3133. if (event.buttons === 1) {
  3134. drag(event, clickListener, getDragListener());
  3135. }
  3136. };
  3137. })();
  3138. }(),
  3139.  
  3140. rotate: new function () {
  3141. this.CODE = 'rotate';
  3142.  
  3143. this.CLASS_ABLE = 'viewfind-action-able-rotate';
  3144.  
  3145. this.onActive = () => {
  3146. this.updateCrosshair();
  3147.  
  3148. addListeners(this);
  3149. };
  3150.  
  3151. this.onInactive = () => {
  3152. addListeners(this, false);
  3153. };
  3154.  
  3155. this.updateCrosshair = () => {
  3156. const angle = DEGREES[90] - rotation.value;
  3157.  
  3158. crosshair.text.innerText = `${Math.floor((DEGREES[90] - rotation.value) / Math.PI * 180)}°\n${Math.round(angle / DEGREES[90]) % 4 * 90}°`;
  3159. };
  3160.  
  3161. this.onScroll = getOnScroll((distance) => {
  3162. rotation.value += distance * $config.get().speeds.rotate;
  3163.  
  3164. rotation.constrain();
  3165.  
  3166. zoom.constrain();
  3167. position.constrain();
  3168.  
  3169. this.updateCrosshair();
  3170. });
  3171.  
  3172. this.onRightClick = (event) => {
  3173. event.stopImmediatePropagation();
  3174. event.preventDefault();
  3175.  
  3176. if (stopDrag) {
  3177. return;
  3178. }
  3179.  
  3180. rotation.value = DEGREES[90];
  3181.  
  3182. rotation.apply();
  3183.  
  3184. zoom.constrain();
  3185. position.constrain();
  3186.  
  3187. this.updateCrosshair();
  3188. };
  3189.  
  3190. this.onMouseDown = (() => {
  3191. const getDragListener = () => {
  3192. const {multipliers} = $config.get();
  3193. const middleX = containers.tracker.clientWidth / 2;
  3194. const middleY = containers.tracker.clientHeight / 2;
  3195.  
  3196. const priorPosition = position.getValues();
  3197. const priorZoom = zoom.value;
  3198.  
  3199. let priorMouseTheta;
  3200.  
  3201. return (event) => {
  3202. const mouseTheta = getTheta(middleX, middleY, event.offsetX, event.offsetY);
  3203.  
  3204. if (priorMouseTheta === undefined) {
  3205. priorMouseTheta = mouseTheta;
  3206.  
  3207. return;
  3208. }
  3209.  
  3210. position.x = priorPosition.x;
  3211. position.y = priorPosition.y;
  3212. zoom.value = priorZoom;
  3213.  
  3214. rotation.value += (priorMouseTheta - mouseTheta) * multipliers.rotate;
  3215.  
  3216. rotation.constrain();
  3217.  
  3218. zoom.constrain();
  3219. position.constrain();
  3220.  
  3221. this.updateCrosshair();
  3222.  
  3223. priorMouseTheta = mouseTheta;
  3224. };
  3225. };
  3226.  
  3227. const clickListener = () => {
  3228. rotation.value = Math.round(rotation.value / DEGREES[90]) * DEGREES[90];
  3229.  
  3230. rotation.constrain();
  3231.  
  3232. zoom.constrain();
  3233. position.constrain();
  3234.  
  3235. this.updateCrosshair();
  3236. };
  3237.  
  3238. return (event) => {
  3239. if (event.buttons === 1) {
  3240. drag(event, clickListener, getDragListener(), containers.tracker);
  3241. }
  3242. };
  3243. })();
  3244. }(),
  3245.  
  3246. configure: new function () {
  3247. this.CODE = 'config';
  3248.  
  3249. this.onActive = async () => {
  3250. await $config.edit();
  3251.  
  3252. updateConfigs();
  3253.  
  3254. viewport.focus();
  3255.  
  3256. glow.reset();
  3257.  
  3258. position.constrain();
  3259. zoom.constrain();
  3260. };
  3261. }(),
  3262.  
  3263. reset: new function () {
  3264. this.CODE = 'reset';
  3265.  
  3266. this.onActive = () => {
  3267. if (this.restore) {
  3268. this.restore();
  3269. } else {
  3270. this.restore = peek();
  3271. }
  3272. };
  3273. }(),
  3274. };
  3275. })();
  3276.  
  3277. const crosshair = new function () {
  3278. this.container = document.createElement('div');
  3279.  
  3280. this.lines = {
  3281. horizontal: document.createElement('div'),
  3282. vertical: document.createElement('div'),
  3283. };
  3284.  
  3285. this.text = document.createElement('div');
  3286.  
  3287. const id = 'viewfind-crosshair';
  3288.  
  3289. this.container.id = id;
  3290. this.container.classList.add(CLASS_VIEWFINDER);
  3291.  
  3292. css.add(`#${id}:not(${css.getSelector(actions.pan.CLASS_ABLE)} *):not(${css.getSelector(actions.rotate.CLASS_ABLE)} *)`, ['display', 'none']);
  3293.  
  3294. this.lines.horizontal.style.position = this.lines.vertical.style.position = this.text.style.position = this.container.style.position = 'absolute';
  3295.  
  3296. this.lines.horizontal.style.top = '50%';
  3297. this.lines.horizontal.style.width = '100%';
  3298.  
  3299. this.lines.vertical.style.left = '50%';
  3300. this.lines.vertical.style.height = '100%';
  3301.  
  3302. this.text.style.userSelect = 'none';
  3303.  
  3304. this.container.style.top = '0';
  3305. this.container.style.width = '100%';
  3306. this.container.style.height = '100%';
  3307. this.container.style.pointerEvents = 'none';
  3308.  
  3309. this.container.append(this.lines.horizontal, this.lines.vertical);
  3310.  
  3311. this.clip = () => {
  3312. const {outer, inner, gap} = $config.get().crosshair;
  3313.  
  3314. const thickness = Math.max(inner, outer);
  3315.  
  3316. const {width, height} = halfDimensions.viewport;
  3317. const halfGap = gap / 2;
  3318.  
  3319. const startInner = (thickness - inner) / 2;
  3320. const startOuter = (thickness - outer) / 2;
  3321.  
  3322. const endInner = thickness - startInner;
  3323. const endOuter = thickness - startOuter;
  3324.  
  3325. this.lines.horizontal.style.clipPath = 'path(\''
  3326. + `M0 ${startOuter}L${width - halfGap} ${startOuter}L${width - halfGap} ${startInner}L${width + halfGap} ${startInner}L${width + halfGap} ${startOuter}L${viewport.clientWidth} ${startOuter}`
  3327. + `L${viewport.clientWidth} ${endOuter}L${width + halfGap} ${endOuter}L${width + halfGap} ${endInner}L${width - halfGap} ${endInner}L${width - halfGap} ${endOuter}L0 ${endOuter}`
  3328. + 'Z\')';
  3329.  
  3330. this.lines.vertical.style.clipPath = 'path(\''
  3331. + `M${startOuter} 0L${startOuter} ${height - halfGap}L${startInner} ${height - halfGap}L${startInner} ${height + halfGap}L${startOuter} ${height + halfGap}L${startOuter} ${viewport.clientHeight}`
  3332. + `L${endOuter} ${viewport.clientHeight}L${endOuter} ${height + halfGap}L${endInner} ${height + halfGap}L${endInner} ${height - halfGap}L${endOuter} ${height - halfGap}L${endOuter} 0`
  3333. + 'Z\')';
  3334. };
  3335.  
  3336. this.updateConfig = (doClip = true) => {
  3337. const {colour, outer, inner, text} = $config.get().crosshair;
  3338. const thickness = Math.max(inner, outer);
  3339.  
  3340. this.container.style.filter = `drop-shadow(${colour.shadow} 0 0 1px)`;
  3341.  
  3342. this.lines.horizontal.style.translate = `0 -${thickness / 2}px`;
  3343. this.lines.vertical.style.translate = `-${thickness / 2}px 0`;
  3344.  
  3345. this.lines.horizontal.style.height = this.lines.vertical.style.width = `${thickness}px`;
  3346.  
  3347. this.lines.horizontal.style.backgroundColor = this.lines.vertical.style.backgroundColor = colour.fill;
  3348.  
  3349. if (text) {
  3350. this.text.style.color = colour.fill;
  3351.  
  3352. this.text.style.font = text.font;
  3353. this.text.style.left = `${text.position.x}%`;
  3354. this.text.style.top = `${text.position.y}%`;
  3355. this.text.style.transform = `translate(${text.translate.x}%,${text.translate.y}%) translate(${text.offset.x}px,${text.offset.y}px)`;
  3356. this.text.style.textAlign = text.align;
  3357. this.text.style.lineHeight = text.height;
  3358.  
  3359. this.container.append(this.text);
  3360. } else {
  3361. this.text.remove();
  3362. }
  3363.  
  3364. if (doClip) {
  3365. this.clip();
  3366. }
  3367. };
  3368.  
  3369. $config.ready.then(() => {
  3370. this.updateConfig(false);
  3371. });
  3372. }();
  3373.  
  3374. // ELEMENT CHANGE LISTENERS
  3375.  
  3376. const observer = new function () {
  3377. const onResolutionChange = () => {
  3378. glow.handleSizeChange?.();
  3379. };
  3380.  
  3381. const styleObserver = new MutationObserver((() => {
  3382. const properties = ['top', 'left', 'width', 'height', 'scale', 'rotate', 'translate', 'transform-origin'];
  3383.  
  3384. let priorStyle;
  3385.  
  3386. return () => {
  3387. // mousemove events on video with ctrlKey=true trigger this but have no effect
  3388. if (video.style.cssText === priorStyle) {
  3389. return;
  3390. }
  3391.  
  3392. priorStyle = video.style.cssText;
  3393.  
  3394. for (const property of properties) {
  3395. containers.background.style[property] = video.style[property];
  3396. containers.foreground.style[property] = video.style[property];
  3397.  
  3398. // cinematics doesn't exist for embedded vids
  3399. if (cinematics) {
  3400. cinematics.style[property] = video.style[property];
  3401. }
  3402. }
  3403.  
  3404. glow.handleViewChange();
  3405. };
  3406. })());
  3407.  
  3408. const videoObserver = new ResizeObserver(() => {
  3409. handleVideoChange();
  3410.  
  3411. glow.handleSizeChange?.();
  3412. });
  3413.  
  3414. const viewportObserver = new ResizeObserver(() => {
  3415. handleViewportChange();
  3416.  
  3417. crosshair.clip();
  3418. });
  3419.  
  3420. this.start = () => {
  3421. video.addEventListener('resize', onResolutionChange);
  3422.  
  3423. styleObserver.observe(video, {attributes: true, attributeFilter: ['style']});
  3424. viewportObserver.observe(viewport);
  3425. videoObserver.observe(video);
  3426.  
  3427. glow.handleViewChange();
  3428. };
  3429.  
  3430. this.stop = () => {
  3431. video.removeEventListener('resize', onResolutionChange);
  3432.  
  3433. styleObserver.disconnect();
  3434. viewportObserver.disconnect();
  3435. videoObserver.disconnect();
  3436. };
  3437. }();
  3438.  
  3439. // NAVIGATION LISTENERS
  3440.  
  3441. const stop = () => {
  3442. if (stopped) {
  3443. return;
  3444. }
  3445.  
  3446. stopped = true;
  3447.  
  3448. enabler.stop();
  3449.  
  3450. stopDrag?.();
  3451.  
  3452. observer.stop();
  3453.  
  3454. containers.background.remove();
  3455. containers.foreground.remove();
  3456. containers.tracker.remove();
  3457. crosshair.container.remove();
  3458.  
  3459. return peek(true);
  3460. };
  3461.  
  3462. const start = () => {
  3463. if (!stopped || viewport.classList.contains('ad-showing')) {
  3464. return;
  3465. }
  3466.  
  3467. stopped = false;
  3468.  
  3469. observer.start();
  3470.  
  3471. glow.start();
  3472.  
  3473. viewport.append(containers.background, containers.foreground, containers.tracker, crosshair.container);
  3474.  
  3475. // User may have a static minimum zoom greater than 1
  3476. zoom.constrain();
  3477.  
  3478. enabler.handleChange();
  3479. };
  3480.  
  3481. const updateConfigs = () => {
  3482. ConfigCache.id++;
  3483.  
  3484. enabler.updateConfig();
  3485. actions.crop.updateConfig();
  3486. crosshair.updateConfig();
  3487. };
  3488.  
  3489. // LISTENER ASSIGNMENTS
  3490.  
  3491. // load & navigation
  3492. (() => {
  3493. const getNode = (node, selector, ...selectors) => new Promise((resolve) => {
  3494. for (const child of node.children) {
  3495. if (child.matches(selector)) {
  3496. resolve(selectors.length === 0 ? child : getNode(child, ...selectors));
  3497.  
  3498. return;
  3499. }
  3500. }
  3501.  
  3502. new MutationObserver((changes, observer) => {
  3503. for (const {addedNodes} of changes) {
  3504. for (const child of addedNodes) {
  3505. if (child.matches(selector)) {
  3506. resolve(selectors.length === 0 ? child : getNode(child, ...selectors));
  3507.  
  3508. observer.disconnect();
  3509.  
  3510. return;
  3511. }
  3512. }
  3513. }
  3514. }).observe(node, {childList: true});
  3515. });
  3516.  
  3517. const setupConfigFailsafe = (parent) => {
  3518. new MutationObserver((changes) => {
  3519. for (const {addedNodes} of changes) {
  3520. for (const node of addedNodes) {
  3521. if (!node.classList.contains('ytp-contextmenu')) {
  3522. continue;
  3523. }
  3524.  
  3525. const container = node.querySelector('.ytp-panel-menu');
  3526. const option = container.lastElementChild.cloneNode(true);
  3527.  
  3528. option.children[0].style.visibility = 'hidden';
  3529. option.children[1].innerText = 'Configure Viewfinding';
  3530.  
  3531. option.addEventListener('click', ({button}) => {
  3532. if (button === 0) {
  3533. actions.configure.onActive();
  3534. }
  3535. });
  3536.  
  3537. container.appendChild(option);
  3538.  
  3539. new ResizeObserver((_, observer) => {
  3540. if (container.clientWidth === 0) {
  3541. option.remove();
  3542.  
  3543. observer.disconnect();
  3544. }
  3545. }).observe(container);
  3546. }
  3547. }
  3548. }).observe(parent, {childList: true});
  3549. };
  3550.  
  3551. const init = async () => {
  3552. if (unsafeWindow.ytplayer?.bootstrapPlayerContainer?.childElementCount > 0) {
  3553. // wait for the video to be moved to ytd-app
  3554. await new Promise((resolve) => {
  3555. new MutationObserver((changes, observer) => {
  3556. resolve();
  3557.  
  3558. observer.disconnect();
  3559. }).observe(unsafeWindow.ytplayer.bootstrapPlayerContainer, {childList: true});
  3560. });
  3561. }
  3562.  
  3563. try {
  3564. await $config.ready;
  3565. } catch (error) {
  3566. if (!$config.reset || !window.confirm(`${error.message}\n\nWould you like to erase your data?`)) {
  3567. console.error(error);
  3568.  
  3569. return;
  3570. }
  3571.  
  3572. await $config.reset();
  3573.  
  3574. updateConfigs();
  3575. }
  3576.  
  3577. if (isEmbed) {
  3578. video = document.body.querySelector(SELECTOR_VIDEO);
  3579. } else {
  3580. const pageManager = await getNode(document.body, 'ytd-app', '#content', 'ytd-page-manager');
  3581.  
  3582. const page = pageManager.getCurrentPage() ?? await new Promise((resolve) => {
  3583. new MutationObserver(([{addedNodes: [page]}], observer) => {
  3584. if (page) {
  3585. resolve(page);
  3586.  
  3587. observer.disconnect();
  3588. }
  3589. }).observe(pageManager, {childList: true});
  3590. });
  3591.  
  3592. await page.playerEl.getPlayerPromise();
  3593.  
  3594. video = page.playerEl.querySelector(SELECTOR_VIDEO);
  3595. cinematics = page.querySelector('#cinematics');
  3596.  
  3597. // navigation to a new video
  3598. new MutationObserver(() => {
  3599. video.removeEventListener('play', startIfReady);
  3600.  
  3601. power.off();
  3602.  
  3603. // this callback can occur after metadata loads
  3604. startIfReady();
  3605. }).observe(page, {attributes: true, attributeFilter: ['video-id']});
  3606.  
  3607. // navigation to a non-video page
  3608. new MutationObserver(() => {
  3609. if (video.src === '') {
  3610. video.removeEventListener('play', startIfReady);
  3611.  
  3612. power.off();
  3613. }
  3614. }).observe(video, {attributes: true, attributeFilter: ['src']});
  3615. }
  3616.  
  3617. viewport = video.parentElement.parentElement;
  3618. altTarget = viewport.parentElement;
  3619.  
  3620. containers.foreground.style.zIndex = crosshair.container.style.zIndex = video.parentElement.computedStyleMap?.().get('z-index').value ?? 10;
  3621. crosshair.clip();
  3622.  
  3623. handleVideoChange();
  3624. handleViewportChange();
  3625.  
  3626. setupConfigFailsafe(document.body);
  3627. setupConfigFailsafe(viewport);
  3628.  
  3629. const startIfReady = () => {
  3630. if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
  3631. start();
  3632. }
  3633. };
  3634.  
  3635. const power = new function () {
  3636. this.off = () => {
  3637. delete this.wake;
  3638.  
  3639. stop();
  3640. };
  3641.  
  3642. this.sleep = () => {
  3643. this.wake ??= stop();
  3644. };
  3645. }();
  3646.  
  3647. new MutationObserver((() => {
  3648. return () => {
  3649. // video end
  3650. if (viewport.classList.contains('ended-mode')) {
  3651. power.off();
  3652.  
  3653. video.addEventListener('play', startIfReady, {once: true});
  3654. // ad start
  3655. } else if (viewport.classList.contains('ad-showing')) {
  3656. power.sleep();
  3657. }
  3658. };
  3659. })()).observe(viewport, {attributes: true, attributeFilter: ['class']});
  3660.  
  3661. // glow initialisation requires video dimensions
  3662. startIfReady();
  3663.  
  3664. video.addEventListener('loadedmetadata', () => {
  3665. if (viewport.classList.contains('ad-showing')) {
  3666. return;
  3667. }
  3668.  
  3669. start();
  3670.  
  3671. if (power.wake) {
  3672. power.wake();
  3673.  
  3674. delete power.wake;
  3675. }
  3676. });
  3677. };
  3678.  
  3679. if (!('ytPageType' in unsafeWindow) || unsafeWindow.ytPageType === 'watch') {
  3680. init();
  3681.  
  3682. return;
  3683. }
  3684.  
  3685. const initListener = ({detail: {newPageType}}) => {
  3686. if (newPageType === 'ytd-watch-flexy') {
  3687. init();
  3688.  
  3689. document.body.removeEventListener('yt-page-type-changed', initListener);
  3690. }
  3691. };
  3692.  
  3693. document.body.addEventListener('yt-page-type-changed', initListener);
  3694. })();
  3695.  
  3696. // keyboard state change
  3697.  
  3698. document.addEventListener('keydown', ({code}) => {
  3699. if (enabler.toggled) {
  3700. enabler.keys[enabler.keys.has(code) ? 'delete' : 'add'](code);
  3701.  
  3702. enabler.handleChange();
  3703. } else if (!enabler.keys.has(code)) {
  3704. enabler.keys.add(code);
  3705.  
  3706. enabler.handleChange();
  3707. }
  3708. });
  3709.  
  3710. document.addEventListener('keyup', ({code}) => {
  3711. if (enabler.toggled) {
  3712. return;
  3713. }
  3714.  
  3715. if (enabler.keys.has(code)) {
  3716. enabler.keys.delete(code);
  3717.  
  3718. enabler.handleChange();
  3719. }
  3720. });
  3721.  
  3722. window.addEventListener('blur', () => {
  3723. if (enabler.toggled) {
  3724. stopDrag?.();
  3725. } else {
  3726. enabler.keys.clear();
  3727.  
  3728. enabler.handleChange();
  3729. }
  3730. });
  3731. })();

QingJ © 2025

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