YouTube Viewfinding

Zoom, rotate & crop YouTube videos.

目前为 2025-02-03 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube Viewfinding
  3. // @version 0.0
  4. // @description Zoom, rotate & crop YouTube videos.
  5. // @author Callum Latham
  6. // @namespace https://greasyfork.org/users/696211-ctl2
  7. // @license GNU GPLv3
  8. // @match *://www.youtube.com/*
  9. // @match *://youtube.com/*
  10. // @require https://update.greasyfork.org/scripts/446506/1531300/%24Config.js
  11. // @grant GM.setValue
  12. // @grant GM.getValue
  13. // @grant GM.deleteValue
  14. // ==/UserScript==
  15.  
  16. /* global $Config */
  17.  
  18. (() => {
  19. const isEmbed = window.location.pathname.split('/')[1] === 'embed';
  20.  
  21. // Don't run in non-embed frames (e.g. stream chat frame)
  22. if (window.parent !== window && !isEmbed) {
  23. return;
  24. }
  25.  
  26. const VAR_ZOOM = '--viewfind-zoom';
  27. const LIMITS = {none: 'None', static: 'Static', fit: 'Fit'};
  28.  
  29. const $config = new $Config(
  30. 'VIEWFIND_TREE',
  31. (() => {
  32. const isCSSRule = (() => {
  33. const wrapper = document.createElement('style');
  34. const regex = /\s/g;
  35. return (property, text) => {
  36. const ruleText = `${property}:${text};`;
  37. document.head.appendChild(wrapper);
  38. wrapper.sheet.insertRule(`:not(*){${ruleText}}`);
  39. const [{style: {cssText}}] = wrapper.sheet.cssRules;
  40. wrapper.remove();
  41. return cssText.replaceAll(regex, '') === ruleText.replaceAll(regex, '') || `Must be a valid CSS ${property} rule`;
  42. };
  43. })();
  44. const getHideId = (() => {
  45. let id = -1;
  46. return () => ++id;
  47. })();
  48. const glowHideId = getHideId();
  49. return {
  50. get: (_, configs) => Object.assign(...configs),
  51. children: [
  52. {
  53. label: 'Controls',
  54. children: [
  55. {
  56. label: 'Keybinds',
  57. descendantPredicate: (children) => {
  58. const isMatch = ({children: a}, {children: b}) => {
  59. if (a.length !== b.length) {
  60. return false;
  61. }
  62. return a.every(({value: keyA}) => b.some(({value: keyB}) => keyA === keyB));
  63. };
  64. for (let i = 1; i < children.length; ++i) {
  65. if (children.slice(i).some((child) => isMatch(children[i - 1], child))) {
  66. return 'Another action has this key combination';
  67. }
  68. }
  69. return true;
  70. },
  71. get: (_, configs) => ({keys: Object.assign(...configs)}),
  72. children: (() => {
  73. const seed = {
  74. value: '',
  75. listeners: {
  76. keydown: (event) => {
  77. switch (event.key) {
  78. case 'Enter':
  79. case 'Escape':
  80. return;
  81. }
  82. event.preventDefault();
  83. event.target.value = event.code;
  84. event.target.dispatchEvent(new InputEvent('input'));
  85. },
  86. },
  87. };
  88. const getKeys = (children) => new Set(children.map(({value}) => value));
  89. const getNode = (label, keys, get) => ({
  90. label,
  91. seed,
  92. children: keys.map((value) => ({...seed, value})),
  93. get,
  94. });
  95. return [
  96. {
  97. label: 'Actions',
  98. get: (_, [toggle, ...controls]) => Object.assign(...controls.map(({id, keys}) => ({
  99. [id]: {
  100. toggle,
  101. keys,
  102. },
  103. }))),
  104. children: [
  105. {
  106. label: 'Toggle?',
  107. value: false,
  108. get: ({value}) => value,
  109. },
  110. ...[
  111. ['Pan / Zoom', ['KeyZ'], 'pan'],
  112. ['Rotate', ['IntlBackslash'], 'rotate'],
  113. ['Crop', ['KeyZ', 'IntlBackslash'], 'crop'],
  114. ].map(([label, keys, id]) => getNode(label, keys, ({children}) => ({id, keys: getKeys(children)}))),
  115. ],
  116. },
  117. getNode('Reset', ['KeyX'], ({children}) => ({reset: {keys: getKeys(children)}})),
  118. getNode('Configure', ['AltLeft', 'KeyX'], ({children}) => ({config: {keys: getKeys(children)}})),
  119. ];
  120. })(),
  121. },
  122. {
  123. label: 'Scroll Speeds',
  124. get: (_, configs) => ({speeds: Object.assign(...configs)}),
  125. children: [
  126. {
  127. label: 'Zoom',
  128. value: -100,
  129. get: ({value}) => ({zoom: value / 150000}),
  130. },
  131. {
  132. label: 'Rotate',
  133. value: -100,
  134. // 150000 * (5 - 0.8) / 2π ≈ 100000
  135. get: ({value}) => ({rotate: value / 100000}),
  136. },
  137. {
  138. label: 'Crop',
  139. value: -100,
  140. get: ({value}) => ({crop: value / 300000}),
  141. },
  142. ],
  143. },
  144. {
  145. label: 'Drag Inversions',
  146. get: (_, configs) => ({multipliers: Object.assign(...configs)}),
  147. children: [
  148. ['Pan', 'pan'],
  149. ['Rotate', 'rotate'],
  150. ['Crop', 'crop'],
  151. ].map(([label, key, value = false]) => ({
  152. label,
  153. value,
  154. get: ({value}) => ({[key]: value ? -1 : 1}),
  155. })),
  156. },
  157. {
  158. label: 'Click Movement Allowance (px)',
  159. value: 2,
  160. predicate: (value) => value >= 0 || 'Allowance must be positive',
  161. inputAttributes: {min: 0},
  162. get: ({value: clickCutoff}) => ({clickCutoff}),
  163. },
  164. ],
  165. },
  166. {
  167. label: 'Behaviour',
  168. children: [
  169. ...(() => {
  170. const typeNode = {
  171. label: 'Type',
  172. get: ({value}) => ({type: value}),
  173. };
  174. const staticNode = {
  175. label: 'Value (%)',
  176. predicate: (value) => value >= 0 || 'Limit must be positive',
  177. inputAttributes: {min: 0},
  178. get: ({value}) => ({custom: value / 100}),
  179. };
  180. const fitNode = {
  181. label: 'Glow Allowance (%)',
  182. predicate: (value) => value >= 0 || 'Allowance must be positive',
  183. inputAttributes: {min: 0},
  184. get: ({value}) => ({frame: value / 100}),
  185. };
  186. const options = Object.values(LIMITS);
  187. const getNode = (label, key, value, customValue, glowAllowance = 300) => {
  188. const staticId = getHideId();
  189. const fitId = getHideId();
  190. const onUpdate = (value) => ({
  191. hide: {
  192. [staticId]: value !== LIMITS.static,
  193. [fitId]: value !== LIMITS.fit,
  194. },
  195. });
  196. return {
  197. label,
  198. get: (_, configs) => ({[key]: Object.assign(...configs)}),
  199. children: [
  200. {...typeNode, value, options, onUpdate},
  201. {...staticNode, value: customValue, hideId: staticId},
  202. {...fitNode, value: glowAllowance, hideId: fitId},
  203. ],
  204. };
  205. };
  206. return [
  207. getNode('Zoom In Limit', 'zoomInLimit', LIMITS.static, 500, 0),
  208. getNode('Zoom Out Limit', 'zoomOutLimit', LIMITS.static, 80),
  209. getNode('Pan Limit', 'panLimit', LIMITS.static, 50),
  210. {
  211. label: 'Snap Pan Limit',
  212. get: (_, configs) => ({snapPanLimit: Object.assign(...configs)}),
  213. children: ((hideId) => [
  214. {
  215. ...typeNode,
  216. value: LIMITS.fit,
  217. options: [LIMITS.none, LIMITS.fit],
  218. onUpdate: (value) => ({hide: {[hideId]: value !== LIMITS.fit}}),
  219. },
  220. {...fitNode, value: 0, hideId},
  221. ])(getHideId()),
  222. },
  223. ];
  224. })(),
  225. {
  226. label: 'Active Effects',
  227. get: (_, configs) => {
  228. const {overlayKill, overlayHide, ...config} = Object.assign(...configs);
  229. return {
  230. active: {
  231. overlayRule: overlayKill && [overlayHide ? 'display' : 'pointer-events', 'none'],
  232. ...config,
  233. },
  234. };
  235. },
  236. children: [
  237. {
  238. label: 'Pause Video?',
  239. value: false,
  240. get: ({value: pause}) => ({pause}),
  241. },
  242. {
  243. label: 'Hide Glow?',
  244. value: false,
  245. get: ({value: hideGlow}) => ({hideGlow}),
  246. hideId: glowHideId,
  247. },
  248. ...((hideId) => [
  249. {
  250. label: 'Disable Overlay?',
  251. value: true,
  252. get: ({value: overlayKill}, configs) => Object.assign({overlayKill}, ...configs),
  253. onUpdate: (value) => ({hide: {[hideId]: !value}}),
  254. children: [
  255. {
  256. label: 'Hide Overlay?',
  257. value: false,
  258. get: ({value: overlayHide}) => ({overlayHide}),
  259. hideId,
  260. },
  261. ],
  262. },
  263. ])(getHideId()),
  264. ],
  265. },
  266. ],
  267. },
  268. {
  269. label: 'Glow',
  270. value: true,
  271. onUpdate: (value) => ({hide: {[glowHideId]: !value}}),
  272. get: ({value: on}, configs) => {
  273. if (!on) {
  274. return {};
  275. }
  276. const {turnover, ...config} = Object.assign(...configs);
  277. const sampleCount = Math.floor(config.fps * turnover);
  278. // avoid taking more samples than there's space for
  279. if (sampleCount > config.size) {
  280. const fps = config.size / turnover;
  281. return {
  282. glow: {
  283. ...config,
  284. sampleCount: config.size,
  285. interval: 1000 / fps,
  286. fps,
  287. },
  288. };
  289. }
  290. return {
  291. glow: {
  292. ...config,
  293. interval: 1000 / config.fps,
  294. sampleCount,
  295. },
  296. };
  297. },
  298. children: [
  299. (() => {
  300. const [seed, getChild] = (() => {
  301. const options = ['blur', 'brightness', 'contrast', 'drop-shadow', 'grayscale', 'hue-rotate', 'invert', 'opacity', 'saturate', 'sepia'];
  302. const ids = {};
  303. const hide = {};
  304. for (const option of options) {
  305. ids[option] = getHideId();
  306. hide[ids[option]] = true;
  307. }
  308. const min0Amount = {
  309. label: 'Amount (%)',
  310. value: 100,
  311. predicate: (value) => value >= 0 || 'Amount must be positive',
  312. inputAttributes: {min: 0},
  313. };
  314. const max100Amount = {
  315. label: 'Amount (%)',
  316. value: 0,
  317. predicate: (value) => {
  318. if (value < 0) {
  319. return 'Amount must be positive';
  320. }
  321. return value <= 100 || 'Amount may not exceed 100%';
  322. },
  323. inputAttributes: {min: 0, max: 100},
  324. };
  325. const getScaled = (value) => `calc(${value}px/var(${VAR_ZOOM}))`;
  326. const root = {
  327. label: 'Function',
  328. options,
  329. value: options[0],
  330. get: ({value}, configs) => {
  331. const config = Object.assign(...configs);
  332. switch (value) {
  333. case options[0]:
  334. return {
  335. filter: config.blurScale ? `blur(${config.blur}px)` : `blur(${getScaled(config.blur)})`,
  336. blur: {
  337. x: config.blur,
  338. y: config.blur,
  339. scale: config.blurScale,
  340. },
  341. };
  342. case options[3]:
  343. return {
  344. filter: config.shadowScale ?
  345. `drop-shadow(${config.shadow} ${config.shadowX}px ${config.shadowY}px ${config.shadowSpread}px)` :
  346. `drop-shadow(${config.shadow} ${getScaled(config.shadowX)} ${getScaled(config.shadowY)} ${getScaled(config.shadowSpread)})`,
  347. blur: {
  348. x: config.shadowSpread + Math.abs(config.shadowX),
  349. y: config.shadowSpread + Math.abs(config.shadowY),
  350. scale: config.shadowScale,
  351. },
  352. };
  353. case options[5]:
  354. return {filter: `hue-rotate(${config.hueRotate}deg)`};
  355. }
  356. return {filter: `${value}(${config[value]}%)`};
  357. },
  358. onUpdate: (value) => ({hide: {...hide, [ids[value]]: false}}),
  359. };
  360. const children = {
  361. 'blur': [
  362. {
  363. label: 'Distance (px)',
  364. value: 0,
  365. get: ({value}) => ({blur: value}),
  366. predicate: (value) => value >= 0 || 'Distance must be positive',
  367. inputAttributes: {min: 0},
  368. hideId: ids.blur,
  369. },
  370. {
  371. label: 'Scale?',
  372. value: false,
  373. get: ({value}) => ({blurScale: value}),
  374. hideId: ids.blur,
  375. },
  376. ],
  377. 'brightness': [
  378. {
  379. ...min0Amount,
  380. hideId: ids.brightness,
  381. get: ({value}) => ({brightness: value}),
  382. },
  383. ],
  384. 'contrast': [
  385. {
  386. ...min0Amount,
  387. hideId: ids.contrast,
  388. get: ({value}) => ({contrast: value}),
  389. },
  390. ],
  391. 'drop-shadow': [
  392. {
  393. label: 'Colour',
  394. input: 'color',
  395. value: '#FFFFFF',
  396. get: ({value}) => ({shadow: value}),
  397. hideId: ids['drop-shadow'],
  398. },
  399. {
  400. label: 'Horizontal Offset (px)',
  401. value: 0,
  402. get: ({value}) => ({shadowX: value}),
  403. hideId: ids['drop-shadow'],
  404. },
  405. {
  406. label: 'Vertical Offset (px)',
  407. value: 0,
  408. get: ({value}) => ({shadowY: value}),
  409. hideId: ids['drop-shadow'],
  410. },
  411. {
  412. label: 'Spread (px)',
  413. value: 0,
  414. predicate: (value) => value >= 0 || 'Spread must be positive',
  415. inputAttributes: {min: 0},
  416. get: ({value}) => ({shadowSpread: value}),
  417. hideId: ids['drop-shadow'],
  418. },
  419. {
  420. label: 'Scale?',
  421. value: true,
  422. get: ({value}) => ({shadowScale: value}),
  423. hideId: ids['drop-shadow'],
  424. },
  425. ],
  426. 'grayscale': [
  427. {
  428. ...max100Amount,
  429. hideId: ids.grayscale,
  430. get: ({value}) => ({grayscale: value}),
  431. },
  432. ],
  433. 'hue-rotate': [
  434. {
  435. label: 'Angle (deg)',
  436. value: 0,
  437. get: ({value}) => ({hueRotate: value}),
  438. hideId: ids['hue-rotate'],
  439. },
  440. ],
  441. 'invert': [
  442. {
  443. ...max100Amount,
  444. hideId: ids.invert,
  445. get: ({value}) => ({invert: value}),
  446. },
  447. ],
  448. 'opacity': [
  449. {
  450. ...max100Amount,
  451. value: 100,
  452. hideId: ids.opacity,
  453. get: ({value}) => ({opacity: value}),
  454. },
  455. ],
  456. 'saturate': [
  457. {
  458. ...min0Amount,
  459. hideId: ids.saturate,
  460. get: ({value}) => ({saturate: value}),
  461. },
  462. ],
  463. 'sepia': [
  464. {
  465. ...max100Amount,
  466. hideId: ids.sepia,
  467. get: ({value}) => ({sepia: value}),
  468. },
  469. ],
  470. };
  471. return [
  472. {...root, children: Object.values(children).flat()}, (id, ...values) => {
  473. const replacements = [];
  474. for (const [i, child] of children[id].entries()) {
  475. replacements.push({...child, value: values[i]});
  476. }
  477. return {
  478. ...root,
  479. value: id,
  480. children: Object.values({...children, [id]: replacements}).flat(),
  481. };
  482. },
  483. ];
  484. })();
  485. return {
  486. label: 'Filter',
  487. get: (_, configs) => {
  488. const scaled = {x: 0, y: 0};
  489. const unscaled = {x: 0, y: 0};
  490. let filter = '';
  491. for (const config of configs) {
  492. filter += config.filter;
  493. if ('blur' in config) {
  494. const target = config.blur.scale ? scaled : unscaled;
  495. target.x = Math.max(target.x, config.blur.x);
  496. target.y = Math.max(target.y, config.blur.y);
  497. }
  498. }
  499. return {filter, blur: {scaled, unscaled}};
  500. },
  501. children: [
  502. getChild('saturate', 150),
  503. getChild('brightness', 150),
  504. getChild('blur', 25, false),
  505. ],
  506. seed,
  507. };
  508. })(),
  509. {
  510. label: 'Update',
  511. childPredicate: ([{value: fps}, {value: turnover}]) => fps * turnover >= 1 || `${turnover} second turnover cannot be achieved at ${fps} hertz`,
  512. children: [
  513. {
  514. label: 'Frequency (Hz)',
  515. value: 15,
  516. predicate: (value) => {
  517. if (value > 144) {
  518. return 'Update frequency may not be above 144 hertz';
  519. }
  520. return value >= 0 || 'Update frequency must be positive';
  521. },
  522. inputAttributes: {min: 0, max: 144},
  523. get: ({value: fps}) => ({fps}),
  524. },
  525. {
  526. label: 'Turnover Time (s)',
  527. value: 3,
  528. predicate: (value) => value >= 0 || 'Turnover time must be positive',
  529. inputAttributes: {min: 0},
  530. get: ({value: turnover}) => ({turnover}),
  531. },
  532. {
  533. label: 'Reverse?',
  534. value: false,
  535. get: ({value: doFlip}) => ({doFlip}),
  536. },
  537. ],
  538. },
  539. {
  540. label: 'Size (px)',
  541. value: 50,
  542. predicate: (value) => value >= 0 || 'Size must be positive',
  543. inputAttributes: {min: 0},
  544. get: ({value}) => ({size: value}),
  545. },
  546. {
  547. label: 'End Point (%)',
  548. value: 103,
  549. predicate: (value) => value >= 0 || 'End point must be positive',
  550. inputAttributes: {min: 0},
  551. get: ({value}) => ({end: value / 100}),
  552. },
  553. ].map((node) => ({...node, hideId: glowHideId})),
  554. },
  555. {
  556. label: 'Interfaces',
  557. children: [
  558. {
  559. label: 'Crop',
  560. get: (_, configs) => ({crop: Object.assign(...configs)}),
  561. children: [
  562. {
  563. label: 'Colours',
  564. get: (_, configs) => ({colour: Object.assign(...configs)}),
  565. children: [
  566. {
  567. label: 'Fill',
  568. get: (_, [colour, opacity]) => ({fill: `${colour}${opacity}`}),
  569. children: [
  570. {
  571. label: 'Colour',
  572. value: '#808080',
  573. input: 'color',
  574. get: ({value}) => value,
  575. },
  576. {
  577. label: 'Opacity (%)',
  578. value: 40,
  579. predicate: (value) => {
  580. if (value < 0) {
  581. return 'Opacity must be positive';
  582. }
  583. return value <= 100 || 'Opacity may not exceed 100%';
  584. },
  585. inputAttributes: {min: 0, max: 100},
  586. get: ({value}) => Math.round(255 * value / 100).toString(16),
  587. },
  588. ],
  589. },
  590. {
  591. label: 'Shadow',
  592. value: '#000000',
  593. input: 'color',
  594. get: ({value: shadow}) => ({shadow}),
  595. },
  596. {
  597. label: 'Border',
  598. value: '#ffffff',
  599. input: 'color',
  600. get: ({value: border}) => ({border}),
  601. },
  602. ],
  603. },
  604. {
  605. label: 'Handle Size (%)',
  606. value: 6,
  607. predicate: (value) => {
  608. if (value < 0) {
  609. return 'Size must be positive';
  610. }
  611. return value <= 50 || 'Size may not exceed 50%';
  612. },
  613. inputAttributes: {min: 0, max: 50},
  614. get: ({value}) => ({handle: value / 100}),
  615. },
  616. ],
  617. },
  618. {
  619. label: 'Crosshair',
  620. get: (value, configs) => ({crosshair: Object.assign(...configs)}),
  621. children: [
  622. {
  623. label: 'Outer Thickness (px)',
  624. value: 3,
  625. predicate: (value) => value >= 0 || 'Thickness must be positive',
  626. inputAttributes: {min: 0},
  627. get: ({value: outer}) => ({outer}),
  628. },
  629. {
  630. label: 'Inner Thickness (px)',
  631. value: 1,
  632. predicate: (value) => value >= 0 || 'Thickness must be positive',
  633. inputAttributes: {min: 0},
  634. get: ({value: inner}) => ({inner}),
  635. },
  636. {
  637. label: 'Inner Diameter (px)',
  638. value: 157,
  639. predicate: (value) => value >= 0 || 'Diameter must be positive',
  640. inputAttributes: {min: 0},
  641. get: ({value: gap}) => ({gap}),
  642. },
  643. ((hideId) => ({
  644. label: 'Text',
  645. value: true,
  646. onUpdate: (value) => ({hide: {[hideId]: !value}}),
  647. get: ({value}, configs) => {
  648. if (!value) {
  649. return {};
  650. }
  651. const {translateX, translateY, ...config} = Object.assign(...configs);
  652. return {
  653. text: {
  654. translate: {
  655. x: translateX,
  656. y: translateY,
  657. },
  658. ...config,
  659. },
  660. };
  661. },
  662. children: [
  663. {
  664. label: 'Font',
  665. value: '30px "Harlow Solid", cursive',
  666. predicate: isCSSRule.bind(null, 'font'),
  667. get: ({value: font}) => ({font}),
  668. },
  669. {
  670. label: 'Position (%)',
  671. get: (_, configs) => ({position: Object.assign(...configs)}),
  672. children: ['x', 'y'].map((label) => ({
  673. label,
  674. value: 0,
  675. predicate: (value) => Math.abs(value) <= 50 || 'Position must be on-screen',
  676. inputAttributes: {min: -50, max: 50},
  677. get: ({value}) => ({[label]: value + 50}),
  678. })),
  679. },
  680. {
  681. label: 'Offset (px)',
  682. get: (_, configs) => ({offset: Object.assign(...configs)}),
  683. children: [
  684. {
  685. label: 'x',
  686. value: -6,
  687. get: ({value: x}) => ({x}),
  688. },
  689. {
  690. label: 'y',
  691. value: -25,
  692. get: ({value: y}) => ({y}),
  693. },
  694. ],
  695. },
  696. (() => {
  697. const options = ['Left', 'Center', 'Right'];
  698. return {
  699. label: 'Alignment',
  700. value: options[2],
  701. options,
  702. get: ({value}) => ({align: value.toLowerCase(), translateX: options.indexOf(value) * -50}),
  703. };
  704. })(),
  705. (() => {
  706. const options = ['Top', 'Middle', 'Bottom'];
  707. return {
  708. label: 'Baseline',
  709. value: options[0],
  710. options,
  711. get: ({value}) => ({translateY: options.indexOf(value) * -50}),
  712. };
  713. })(),
  714. {
  715. label: 'Line height (%)',
  716. value: 90,
  717. predicate: (value) => value >= 0 || 'Height must be positive',
  718. inputAttributes: {min: 0},
  719. get: ({value}) => ({height: value / 100}),
  720. },
  721. ].map((node) => ({...node, hideId})),
  722. }))(getHideId()),
  723. {
  724. label: 'Colours',
  725. get: (_, configs) => ({colour: Object.assign(...configs)}),
  726. children: [
  727. {
  728. label: 'Fill',
  729. value: '#ffffff',
  730. input: 'color',
  731. get: ({value: fill}) => ({fill}),
  732. },
  733. {
  734. label: 'Shadow',
  735. value: '#000000',
  736. input: 'color',
  737. get: ({value: shadow}) => ({shadow}),
  738. },
  739. ],
  740. },
  741. ],
  742. },
  743. ],
  744. },
  745. ],
  746. };
  747. })(),
  748. {
  749. headBase: '#c80000',
  750. headButtonExit: '#000000',
  751. borderHead: '#ffffff',
  752. borderTooltip: '#c80000',
  753. width: Math.min(90, screen.width / 16),
  754. height: 90,
  755. },
  756. {
  757. zIndex: 10000,
  758. scrollbarColor: 'initial',
  759. },
  760. );
  761.  
  762. const CLASS_VIEWFINDER = 'viewfind-element';
  763. const PI_HALVES = [Math.PI / 2, Math.PI, 3 * Math.PI / 2, Math.PI * 2];
  764. const SELECTOR_VIDEO = '#movie_player video.html5-main-video';
  765.  
  766. // STATE
  767.  
  768. let video;
  769. let altTarget;
  770. let viewport;
  771. let cinematics;
  772.  
  773. let stopped = true;
  774. let stopDrag;
  775.  
  776. const viewportAngles = new function () {
  777. this.set = () => {
  778. this.side = getTheta(0, 0, viewport.clientWidth, viewport.clientHeight);
  779. // equals `getTheta(0, 0, viewport.clientHeight, viewport.clientWidth)`
  780. this.base = PI_HALVES[0] - this.side;
  781. glow.handleViewChange(true);
  782. };
  783. }();
  784.  
  785. // ROTATION HELPERS
  786.  
  787. const getTheta = (fromX, fromY, toX, toY) => Math.atan2(toY - fromY, toX - fromX);
  788.  
  789. const getGenericRotated = (x, y, angle) => {
  790. const radius = Math.sqrt(x * x + y * y);
  791. const pointTheta = getTheta(0, 0, x, y) + angle;
  792. return {
  793. x: radius * Math.cos(pointTheta),
  794. y: radius * Math.sin(pointTheta),
  795. };
  796. };
  797.  
  798. const getRotated = (xRaw, yRaw, ratio = true) => {
  799. // Multiplying by video dimensions to have the axes' scales match the video's
  800. // Using position's raw values would only work if points moved elliptically around the centre of rotation
  801. const rotated = getGenericRotated(xRaw * video.clientWidth, yRaw * video.clientHeight, rotation.value - PI_HALVES[0]);
  802. return ratio ? {x: rotated.x / video.clientWidth, y: rotated.y / video.clientHeight} : rotated;
  803. };
  804.  
  805. // CSS HELPER
  806.  
  807. const css = new function () {
  808. this.has = (name) => document.body.classList.contains(name);
  809. this.tag = (name, doAdd = true) => document.body.classList[doAdd ? 'add' : 'remove'](name);
  810. this.getSelector = (...classes) => `body.${classes.join('.')}`;
  811. const getSheet = () => {
  812. const element = document.createElement('style');
  813. document.head.appendChild(element);
  814. return element.sheet;
  815. };
  816. const getRuleString = (selector, ...declarations) => `${selector}{${declarations.map(([property, value]) => `${property}:${value};`).join('')}}`;
  817. this.add = function (...rule) {
  818. this.insertRule(getRuleString(...rule));
  819. }.bind(getSheet());
  820. this.Toggleable = class {
  821. static sheet = getSheet();
  822. static active = [];
  823. static id = 0;
  824. static add(rule, id) {
  825. this.sheet.insertRule(rule, this.active.length);
  826. this.active.push(id);
  827. }
  828. static remove(id) {
  829. let index = this.active.indexOf(id);
  830. while (index >= 0) {
  831. this.sheet.deleteRule(index);
  832. this.active.splice(index, 1);
  833. index = this.active.indexOf(id);
  834. }
  835. }
  836. id = this.constructor.id++;
  837. add(...rule) {
  838. this.constructor.add(getRuleString(...rule), this.id);
  839. }
  840. remove() {
  841. this.constructor.remove(this.id);
  842. }
  843. };
  844. }();
  845.  
  846. // ACTION MANAGER
  847.  
  848. const enabler = new function () {
  849. this.CLASS_ABLE = 'viewfind-action-able';
  850. this.CLASS_DRAGGING = 'viewfind-action-dragging';
  851. this.keys = new Set();
  852. this.didPause = false;
  853. this.isHidingGlow = false;
  854. this.setActive = (action) => {
  855. const {active, keys} = $config.get();
  856. if (active.hideGlow && Boolean(action) !== this.isHidingGlow) {
  857. if (action) {
  858. this.isHidingGlow = true;
  859. glow.hide();
  860. } else if (this.isHidingGlow) {
  861. this.isHidingGlow = false;
  862. glow.show();
  863. }
  864. }
  865. this.activeAction?.onInactive?.();
  866. if (action) {
  867. this.activeAction = action;
  868. this.toggled = keys[action.CODE].toggle;
  869. action.onActive?.();
  870. if (active.pause && !video.paused) {
  871. video.pause();
  872. this.didPause = true;
  873. }
  874. return;
  875. }
  876. if (this.didPause) {
  877. video.play();
  878. this.didPause = false;
  879. }
  880. this.activeAction = this.toggled = undefined;
  881. };
  882. this.handleChange = () => {
  883. if (stopped || stopDrag || video.ended) {
  884. return;
  885. }
  886. const {keys} = $config.get();
  887. let activeAction;
  888. for (const action of Object.values(actions)) {
  889. if (
  890. !this.keys.isSupersetOf(keys[action.CODE].keys) || activeAction && ('toggle' in keys[action.CODE] ?
  891. !('toggle' in keys[activeAction.CODE]) || keys[activeAction.CODE].keys.size >= keys[action.CODE].keys.size :
  892. !('toggle' in keys[activeAction.CODE]) && keys[activeAction.CODE].keys.size >= keys[action.CODE].keys.size)
  893. ) {
  894. if ('CLASS_ABLE' in action) {
  895. css.tag(action.CLASS_ABLE, false);
  896. }
  897. continue;
  898. }
  899. if (activeAction && 'CLASS_ABLE' in activeAction) {
  900. css.tag(activeAction.CLASS_ABLE, false);
  901. }
  902. activeAction = action;
  903. }
  904. if (activeAction === this.activeAction) {
  905. return;
  906. }
  907. if (activeAction) {
  908. if ('CLASS_ABLE' in activeAction) {
  909. css.tag(activeAction.CLASS_ABLE);
  910. css.tag(this.CLASS_ABLE);
  911. this.setActive(activeAction);
  912. return;
  913. }
  914. this.activeAction?.onInactive?.();
  915. activeAction.onActive();
  916. this.activeAction = activeAction;
  917. }
  918. css.tag(this.CLASS_ABLE, false);
  919. this.setActive(false);
  920. };
  921. this.stop = () => {
  922. css.tag(this.CLASS_ABLE, false);
  923. for (const action of Object.values(actions)) {
  924. if ('CLASS_ABLE' in action) {
  925. css.tag(action.CLASS_ABLE, false);
  926. }
  927. }
  928. this.setActive(false);
  929. };
  930. this.updateConfig = (() => {
  931. const rule = new css.Toggleable();
  932. const selector = `${css.getSelector(this.CLASS_ABLE)} #contentContainer.tp-yt-app-drawer[swipe-open]::after`
  933. + `,${css.getSelector(this.CLASS_ABLE)} #movie_player > .html5-video-container ~ :not(.${CLASS_VIEWFINDER})`;
  934. return () => {
  935. const {overlayRule} = $config.get().active;
  936. rule.remove();
  937. if (overlayRule) {
  938. rule.add(selector, overlayRule);
  939. }
  940. };
  941. })();
  942. $config.ready.then(() => {
  943. this.updateConfig();
  944. });
  945. // insertion order decides priority
  946. css.add(`${css.getSelector(this.CLASS_DRAGGING)} #movie_player`, ['cursor', 'grabbing']);
  947. css.add(`${css.getSelector(this.CLASS_ABLE)} #movie_player`, ['cursor', 'grab']);
  948. }();
  949.  
  950. // ELEMENT CONTAINER SETUP
  951.  
  952. const containers = new function () {
  953. for (const name of ['background', 'foreground', 'tracker']) {
  954. this[name] = document.createElement('div');
  955. this[name].classList.add(CLASS_VIEWFINDER);
  956. }
  957. // make an outline of the uncropped video
  958. css.add(`${css.getSelector(enabler.CLASS_ABLE)} #${this.background.id = 'viewfind-container-background'}`, ['outline', '1px solid white']);
  959. this.background.style.position = this.foreground.style.position = 'absolute';
  960. this.background.style.pointerEvents = this.foreground.style.pointerEvents = this.tracker.style.pointerEvents = 'none';
  961. this.tracker.style.height = this.tracker.style.width = '100%';
  962. }();
  963.  
  964. // MODIFIERS
  965.  
  966. class Cache {
  967. targets = [];
  968. constructor(...targets) {
  969. for (const source of targets) {
  970. this.targets.push({source});
  971. }
  972. }
  973. update(target) {
  974. return target.value !== (target.value = target.source.value);
  975. }
  976. isStale() {
  977. return this.targets.reduce((value, target) => value || this.update(target), false);
  978. }
  979. }
  980.  
  981. class ConfigCache extends Cache {
  982. static id = 0;
  983. id = this.constructor.id;
  984. constructor(...targets) {
  985. super(...targets);
  986. }
  987. isStale() {
  988. if (this.id === (this.id = this.constructor.id)) {
  989. return super.isStale();
  990. }
  991. for (const target of this.targets) {
  992. target.value = target.source.value;
  993. }
  994. return true;
  995. }
  996. }
  997.  
  998. const zoom = new function () {
  999. this.value = 1;
  1000. const scaleRule = new css.Toggleable();
  1001. this.reset = () => {
  1002. this.value = 1;
  1003. video.style.removeProperty('scale');
  1004. scaleRule.remove();
  1005. scaleRule.add(':root', [VAR_ZOOM, '1']);
  1006. };
  1007. this.apply = () => {
  1008. video.style.setProperty('scale', `${this.value}`);
  1009. scaleRule.remove();
  1010. scaleRule.add(':root', [VAR_ZOOM, `${this.value}`]);
  1011. delete actions.reset.prior;
  1012. };
  1013. this.getFit = (width = 1, height = 1) => {
  1014. const corner0 = getRotated(width, height, false);
  1015. const corner1 = getRotated(-width, height, false);
  1016. return 1 / Math.max(
  1017. Math.abs(corner0.x) / viewport.clientWidth, Math.abs(corner1.x) / viewport.clientWidth,
  1018. Math.abs(corner0.y) / viewport.clientHeight, Math.abs(corner1.y) / viewport.clientHeight,
  1019. );
  1020. };
  1021. this.constrain = (() => {
  1022. const limitGetters = {
  1023. [LIMITS.static]: ({custom}) => custom,
  1024. [LIMITS.fit]: ({frame}, glow) => {
  1025. if (glow) {
  1026. const base = glow.end - 1;
  1027. const {scaled, unscaled} = glow.blur;
  1028. return this.getFit(
  1029. 1 + Math.max(0, base + Math.max(unscaled.x / video.clientWidth, scaled.x * this.value / video.clientWidth)) * frame,
  1030. 1 + Math.max(0, base + Math.max(unscaled.y / video.clientHeight, scaled.y * this.value / video.clientHeight)) * frame,
  1031. );
  1032. }
  1033. return this.getFit();
  1034. },
  1035. };
  1036. return () => {
  1037. const {zoomOutLimit, zoomInLimit, glow} = $config.get();
  1038. if (zoomOutLimit.type !== 'None') {
  1039. this.value = Math.max(limitGetters[zoomOutLimit.type](zoomOutLimit, glow), this.value);
  1040. }
  1041. if (zoomInLimit.type !== 'None') {
  1042. this.value = Math.min(limitGetters[zoomInLimit.type](zoomInLimit, glow), this.value);
  1043. }
  1044. this.apply();
  1045. };
  1046. })();
  1047. }();
  1048.  
  1049. const rotation = new function () {
  1050. this.value = PI_HALVES[0];
  1051. this.reset = () => {
  1052. this.value = PI_HALVES[0];
  1053. video.style.removeProperty('rotate');
  1054. };
  1055. this.apply = () => {
  1056. // Conversion from anticlockwise rotation from the x-axis to clockwise rotation from the y-axis
  1057. video.style.setProperty('rotate', `${PI_HALVES[0] - this.value}rad`);
  1058. delete actions.reset.prior;
  1059. };
  1060. // dissimilar from other constrain functions in that no effective limit is applied
  1061. // -1.5π < rotation <= 0.5π
  1062. // 0 <= 0.5π - rotation < 2π
  1063. this.constrain = () => {
  1064. this.value %= PI_HALVES[3];
  1065. if (this.value > PI_HALVES[0]) {
  1066. this.value -= PI_HALVES[3];
  1067. } else if (this.value <= -PI_HALVES[2]) {
  1068. this.value += PI_HALVES[3];
  1069. }
  1070. this.apply();
  1071. };
  1072. }();
  1073.  
  1074. const position = new function () {
  1075. this.x = this.y = 0;
  1076. this.getValues = () => ({x: this.x, y: this.y});
  1077. this.reset = () => {
  1078. this.x = this.y = 0;
  1079. video.style.removeProperty('translate');
  1080. };
  1081. this.apply = () => {
  1082. const {x, y} = getRotated(this.x, this.y);
  1083. video.style.setProperty('translate', `${-x * zoom.value * 100}% ${y * zoom.value * 100}%`);
  1084. delete actions.reset.prior;
  1085. };
  1086. this.constrain = (() => {
  1087. const applyFrameValues = (lowCorner, highCorner, sub, main) => {
  1088. this[sub] = Math.max(-lowCorner[sub], Math.min(highCorner[sub], this[sub]));
  1089. const progress = (this[sub] + lowCorner[sub]) / (highCorner[sub] + lowCorner[sub]);
  1090. if (this[main] < 0) {
  1091. const bound = Number.isNaN(progress) ?
  1092. -lowCorner[main] :
  1093. (lowCorner[main] - highCorner[main]) * progress - lowCorner[main];
  1094. this[main] = Math.max(this[main], bound);
  1095. } else {
  1096. const bound = Number.isNaN(progress) ?
  1097. lowCorner[main] :
  1098. (highCorner[main] - lowCorner[main]) * progress + lowCorner[main];
  1099. this[main] = Math.min(this[main], bound);
  1100. }
  1101. };
  1102. const applyFrame = (firstCorner, secondCorner, firstCornerAngle, secondCornerAngle) => {
  1103. // The anti-clockwise angle from the first (top left) corner
  1104. const midPointAngle = (getTheta(0, 0, this.x, this.y) + PI_HALVES[1] + firstCornerAngle) % PI_HALVES[3];
  1105. if (midPointAngle % PI_HALVES[1] < secondCornerAngle) {
  1106. // Frame is x-bound
  1107. const [lowCorner, highCorner] = this.x >= 0 ? [firstCorner, secondCorner] : [secondCorner, firstCorner];
  1108. applyFrameValues(lowCorner, highCorner, 'y', 'x');
  1109. } else {
  1110. // Frame is y-bound
  1111. const [lowCorner, highCorner] = this.y >= 0 ? [firstCorner, secondCorner] : [secondCorner, firstCorner];
  1112. applyFrameValues(lowCorner, highCorner, 'x', 'y');
  1113. }
  1114. };
  1115. const getBoundApplyFrame = (() => {
  1116. const getCorner = (first, second) => {
  1117. if (zoom.value < first.z) {
  1118. return {x: 0, y: 0};
  1119. }
  1120. if (zoom.value < second.z) {
  1121. const progress = (1 / zoom.value - 1 / first.z) / (1 / second.z - 1 / first.z);
  1122. return {
  1123. x: progress * (second.x - first.x) + first.x,
  1124. y: progress * (second.y - first.y) + first.y,
  1125. };
  1126. }
  1127. return {
  1128. x: Math.max(0, 0.5 - (0.5 - second.x) / (zoom.value / second.z)),
  1129. y: Math.max(0, 0.5 - (0.5 - second.y) / (zoom.value / second.z)),
  1130. };
  1131. };
  1132. return (first0, second0, first1, second1) => {
  1133. const fFirstCorner = getCorner(first0, second0);
  1134. const fSecondCorner = getCorner(first1, second1);
  1135. const fFirstCornerAngle = getTheta(0, 0, fFirstCorner.x, fFirstCorner.y);
  1136. const fSecondCornerAngle = fFirstCornerAngle + getTheta(0, 0, fSecondCorner.x, fSecondCorner.y);
  1137. return applyFrame.bind(null, fFirstCorner, fSecondCorner, fFirstCornerAngle, fSecondCornerAngle);
  1138. };
  1139. })();
  1140. // https://math.stackexchange.com/questions/2223691/intersect-2-lines-at-the-same-ratio-through-a-point
  1141. const snapZoom = (() => {
  1142. const isAbove = (x, y, m, c) => m * x + c < y;
  1143. const getPSecond = (low, high) => 1 - low / high;
  1144. const getPFirst = (low, high, target) => (target - low) / (high - low);
  1145. const getProgressed = (p, [fromX, fromY], [toX, toY]) => [p * (toX - fromX) + fromX, p * (toY - fromY) + fromY];
  1146. const getFlipped = (first, second, flipX, flipY) => {
  1147. const flippedFirst = [];
  1148. const flippedSecond = [];
  1149. const corner = [];
  1150. if (flipX) {
  1151. flippedFirst[0] = -first.x;
  1152. flippedSecond[0] = -second.x;
  1153. corner[0] = -0.5;
  1154. } else {
  1155. flippedFirst[0] = first.x;
  1156. flippedSecond[0] = second.x;
  1157. corner[0] = 0.5;
  1158. }
  1159. if (flipY) {
  1160. flippedFirst[1] = -first.y;
  1161. flippedSecond[1] = -second.y;
  1162. corner[1] = -0.5;
  1163. } else {
  1164. flippedFirst[1] = first.y;
  1165. flippedSecond[1] = second.y;
  1166. corner[1] = 0.5;
  1167. }
  1168. return [flippedFirst, flippedSecond, corner];
  1169. };
  1170. const getIntersectPSecond = ([[from0X, from0Y], [to0X, to0Y]], [[from1X, from1Y], [to1X, to1Y]], doFlip) => {
  1171. const x = Math.abs(position.x);
  1172. const y = Math.abs(position.y);
  1173. const d = to0Y;
  1174. const e = from0Y;
  1175. const f = to0X;
  1176. const g = from0X;
  1177. const h = to1Y;
  1178. const i = from1Y;
  1179. const j = to1X;
  1180. const k = from1X;
  1181. const a = d * j - d * k - j * e + e * k - h * f + h * g + i * f - i * g;
  1182. 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;
  1183. const c = k * e - e * x - k * y - g * i + i * x + g * y;
  1184. return (doFlip ? -b - Math.sqrt(b * b - 4 * a * c) : -b + Math.sqrt(b * b - 4 * a * c)) / (2 * a);
  1185. };
  1186. const applyZoomPairSecond = ([z, ...pair], doFlip) => {
  1187. const p = getIntersectPSecond(...pair, doFlip);
  1188. if (p >= 0) {
  1189. zoom.value = p >= 1 ? Number.MAX_SAFE_INTEGER : z / (1 - p);
  1190. return true;
  1191. }
  1192. return false;
  1193. };
  1194. const applyZoomPairFirst = ([z0, z1, ...pair], doFlip) => {
  1195. const p = getIntersectPSecond(...pair, doFlip);
  1196. if (p >= 0) {
  1197. zoom.value = p * (z1 - z0) + z0;
  1198. return true;
  1199. }
  1200. return false;
  1201. };
  1202. return (first0, second0, first1, second1) => {
  1203. const getPairings = (flipX0, flipY0, flipX1, flipY1) => {
  1204. const [flippedFirst0, flippedSecond0, corner0] = getFlipped(first0, second0, flipX0, flipY0);
  1205. const [flippedFirst1, flippedSecond1, corner1] = getFlipped(first1, second1, flipX1, flipY1);
  1206. if (second0.z > second1.z) {
  1207. const progressedHigh = getProgressed(getPSecond(second1.z, second0.z), flippedSecond1, corner1);
  1208. const pairHigh = [
  1209. second0.z,
  1210. [flippedSecond0, corner0],
  1211. [progressedHigh, corner1],
  1212. ];
  1213. if (second1.z > first0.z) {
  1214. const progressedLow = getProgressed(getPFirst(first0.z, second0.z, second1.z), flippedFirst0, flippedSecond0);
  1215. return [
  1216. pairHigh,
  1217. [
  1218. second1.z,
  1219. second0.z,
  1220. [progressedLow, flippedSecond0],
  1221. [flippedSecond1, progressedHigh],
  1222. ],
  1223. ];
  1224. }
  1225. const progressedLow = getProgressed(getPSecond(second1.z, first0.z), flippedSecond1, corner1);
  1226. return [
  1227. pairHigh,
  1228. [
  1229. first0.z,
  1230. second0.z,
  1231. [flippedFirst0, flippedSecond0],
  1232. [progressedLow, progressedHigh],
  1233. ],
  1234. ];
  1235. }
  1236. const progressedHigh = getProgressed(getPSecond(second0.z, second1.z), flippedSecond0, corner0);
  1237. const pairHigh = [
  1238. second1.z,
  1239. [progressedHigh, corner0],
  1240. [flippedSecond1, corner1],
  1241. ];
  1242. if (second0.z > first1.z) {
  1243. const progressedLow = getProgressed(getPFirst(first1.z, second1.z, second0.z), flippedFirst1, flippedSecond1);
  1244. return [
  1245. pairHigh,
  1246. [
  1247. second0.z,
  1248. second1.z,
  1249. [progressedLow, flippedSecond1],
  1250. [flippedSecond0, progressedHigh],
  1251. ],
  1252. ];
  1253. }
  1254. const progressedLow = getProgressed(getPSecond(second0.z, first1.z), flippedSecond0, corner0);
  1255. return [
  1256. pairHigh,
  1257. [
  1258. first1.z,
  1259. second1.z,
  1260. [flippedFirst1, flippedSecond1],
  1261. [progressedLow, progressedHigh],
  1262. ],
  1263. ];
  1264. };
  1265. const [pair0, pair1, doFlip = false] = (() => {
  1266. const doInvert = position.x >= 0 === position.y < 0;
  1267. if (doInvert) {
  1268. const m = (second0.y - 0.5) / (second0.x - 0.5);
  1269. const c = 0.5 - m * 0.5;
  1270. if (isAbove(Math.abs(position.x), Math.abs(position.y), m, c)) {
  1271. return [...getPairings(false, false, true, false), true];
  1272. }
  1273. return getPairings(false, false, false, true);
  1274. }
  1275. const m = (second1.y - 0.5) / (second1.x - 0.5);
  1276. const c = 0.5 - m * 0.5;
  1277. if (isAbove(Math.abs(position.x), Math.abs(position.y), m, c)) {
  1278. return getPairings(true, false, false, false);
  1279. }
  1280. return [...getPairings(false, true, false, false), true];
  1281. })();
  1282. if (applyZoomPairSecond(pair0, doFlip) || applyZoomPairFirst(pair1, doFlip)) {
  1283. return;
  1284. }
  1285. zoom.value = pair1[0];
  1286. };
  1287. })();
  1288. const getZoomPoints = (mod) => {
  1289. const [videoWidth, videoHeight] = (() => {
  1290. const {glow} = $config.get();
  1291. if (glow) {
  1292. const {scaled, unscaled} = glow.blur;
  1293. return [
  1294. (video.clientWidth + Math.max(0, glow.end * video.clientWidth - video.clientWidth + Math.max(unscaled.x, scaled.x * zoom.value)) * mod) / 2,
  1295. (video.clientHeight + Math.max(0, glow.end * video.clientHeight - video.clientHeight + Math.max(unscaled.y, scaled.y * zoom.value)) * mod) / 2,
  1296. ];
  1297. }
  1298. return [video.clientWidth / 2, video.clientHeight / 2];
  1299. })();
  1300. const viewportWidth = viewport.clientWidth / 2;
  1301. const viewportHeight = viewport.clientHeight / 2;
  1302. const quadrant = Math.floor(rotation.value / PI_HALVES[0]) + 3;
  1303. const [xAngle, yAngle] = (() => {
  1304. const angle = (rotation.value + PI_HALVES[3]) % PI_HALVES[0];
  1305. return quadrant % 2 === 0 ? [PI_HALVES[0] - angle, angle] : [angle, PI_HALVES[0] - angle];
  1306. })();
  1307. const progress = xAngle / PI_HALVES[0] * 2 - 1;
  1308. // equivalent:
  1309. // const progress = (yAngle / PI_HALVES[0]) * -2 + 1;
  1310. const cornerAZero = (() => {
  1311. const angleA = progress * viewportAngles.side;
  1312. const angleB = PI_HALVES[0] - angleA - yAngle;
  1313. return {
  1314. // todo broken i guess :)
  1315. x: Math.abs(viewportWidth * Math.sin(angleA) / (videoWidth * Math.cos(angleB))),
  1316. y: Math.abs(viewportWidth * Math.cos(angleB) / (videoHeight * Math.cos(angleA))),
  1317. };
  1318. })();
  1319. const cornerBZero = (() => {
  1320. const angleA = progress * viewportAngles.base;
  1321. const angleB = PI_HALVES[0] - angleA - yAngle;
  1322. return {
  1323. x: Math.abs(viewportHeight * Math.cos(angleA) / (videoWidth * Math.cos(angleB))),
  1324. y: Math.abs(viewportHeight * Math.sin(angleB) / (videoHeight * Math.cos(angleA))),
  1325. };
  1326. })();
  1327. const [cornerAX, cornerAY, cornerBX, cornerBY] = (() => {
  1328. const getCornerA = (() => {
  1329. const angleA = progress * viewportAngles.side;
  1330. const angleB = PI_HALVES[0] - angleA - yAngle;
  1331. return (zoom) => {
  1332. const h = viewportWidth / zoom / Math.cos(angleA);
  1333. const xBound = Math.max(0, videoWidth - Math.sin(angleB) * h);
  1334. const yBound = Math.max(0, videoHeight - Math.cos(angleB) * h);
  1335. return {
  1336. x: xBound / video.clientWidth,
  1337. y: yBound / video.clientHeight,
  1338. };
  1339. };
  1340. })();
  1341. const getCornerB = (() => {
  1342. const angleA = progress * viewportAngles.base;
  1343. const angleB = PI_HALVES[0] - angleA - yAngle;
  1344. return (zoom) => {
  1345. const h = viewportHeight / zoom / Math.cos(angleA);
  1346. const xBound = Math.max(0, videoWidth - Math.cos(angleB) * h);
  1347. const yBound = Math.max(0, videoHeight - Math.sin(angleB) * h);
  1348. return {
  1349. x: xBound / video.clientWidth,
  1350. y: yBound / video.clientHeight,
  1351. };
  1352. };
  1353. })();
  1354. return [
  1355. getCornerA(cornerAZero.x),
  1356. getCornerA(cornerAZero.y),
  1357. getCornerB(cornerBZero.x),
  1358. getCornerB(cornerBZero.y),
  1359. ];
  1360. })();
  1361. const cornerAVars = cornerAZero.x < cornerAZero.y ?
  1362. [{z: cornerAZero.x, ...cornerAX}, {z: cornerAZero.y, ...cornerAY}] :
  1363. [{z: cornerAZero.y, ...cornerAY}, {z: cornerAZero.x, ...cornerAX}];
  1364. const cornerBVars = cornerBZero.x < cornerBZero.y ?
  1365. [{z: cornerBZero.x, ...cornerBX}, {z: cornerBZero.y, ...cornerBY}] :
  1366. [{z: cornerBZero.y, ...cornerBY}, {z: cornerBZero.x, ...cornerBX}];
  1367. return quadrant % 2 === 0 ? [...cornerAVars, ...cornerBVars] : [...cornerBVars, ...cornerAVars];
  1368. };
  1369. const handlers = {
  1370. [LIMITS.static]: ({custom: ratio}) => {
  1371. const bound = 0.5 + (ratio - 0.5) / zoom.value;
  1372. position.x = Math.max(-bound, Math.min(bound, position.x));
  1373. position.y = Math.max(-bound, Math.min(bound, position.y));
  1374. },
  1375. [LIMITS.fit]: (() => {
  1376. const cache = new ConfigCache(rotation, zoom);
  1377. let boundApplyFrame;
  1378. return ({frame}) => {
  1379. if (cache.isStale()) {
  1380. boundApplyFrame = getBoundApplyFrame(...getZoomPoints(frame));
  1381. }
  1382. boundApplyFrame();
  1383. };
  1384. })(),
  1385. };
  1386. const snapHandlers = {
  1387. [LIMITS.fit]: (() => {
  1388. const cache = new ConfigCache(rotation, zoom);
  1389. let boundSnapZoom;
  1390. return ({frame}) => {
  1391. if (cache.isStale()) {
  1392. boundSnapZoom = snapZoom.bind(null, ...getZoomPoints(frame));
  1393. }
  1394. boundSnapZoom();
  1395. zoom.constrain();
  1396. };
  1397. })(),
  1398. };
  1399. return (doZoom = false) => {
  1400. const {panLimit, snapPanLimit} = $config.get();
  1401. if (doZoom) {
  1402. snapHandlers[snapPanLimit.type]?.(snapPanLimit);
  1403. }
  1404. handlers[panLimit.type]?.(panLimit);
  1405. this.apply();
  1406. };
  1407. })();
  1408. }();
  1409.  
  1410. const crop = new function () {
  1411. this.top = this.right = this.bottom = this.left = 0;
  1412. this.getValues = () => ({top: this.top, right: this.right, bottom: this.bottom, left: this.left});
  1413. this.reveal = () => {
  1414. this.top = this.right = this.bottom = this.left = 0;
  1415. rule.remove();
  1416. };
  1417. this.reset = () => {
  1418. this.reveal();
  1419. actions.crop.reset();
  1420. };
  1421. const rule = new css.Toggleable();
  1422. this.apply = () => {
  1423. rule.remove();
  1424. rule.add(
  1425. `${SELECTOR_VIDEO}:not(.${this.CLASS_ABLE} *)`,
  1426. ['clip-path', `inset(${this.top * 100}% ${this.right * 100}% ${this.bottom * 100}% ${this.left * 100}%)`],
  1427. );
  1428. delete actions.reset.prior;
  1429. glow.handleViewChange();
  1430. glow.reset();
  1431. };
  1432. this.getDimensions = (width = video.clientWidth, height = video.clientHeight) => [
  1433. width * (1 - this.left - this.right),
  1434. height * (1 - this.top - this.bottom),
  1435. ];
  1436. }();
  1437.  
  1438. // FUNCTIONALITY
  1439.  
  1440. const glow = (() => {
  1441. const videoCanvas = new OffscreenCanvas(0, 0);
  1442. const videoCtx = videoCanvas.getContext('2d', {alpha: false});
  1443. const glowCanvas = document.createElement('canvas');
  1444. const glowCtx = glowCanvas.getContext('2d', {alpha: false});
  1445. glowCanvas.style.setProperty('position', 'absolute');
  1446. class Sector {
  1447. canvas = new OffscreenCanvas(0, 0);
  1448. ctx = this.canvas.getContext('2d', {alpha: false});
  1449. update(doFill) {
  1450. if (doFill) {
  1451. this.fill();
  1452. } else {
  1453. this.shift();
  1454. this.take();
  1455. }
  1456. this.giveEdge();
  1457. if (this.hasCorners) {
  1458. this.giveCorners();
  1459. }
  1460. }
  1461. }
  1462. class Side extends Sector {
  1463. setDimensions(doShiftRight, sWidth, sHeight, sx, sy, dx, dy, dWidth, dHeight) {
  1464. this.canvas.width = sWidth;
  1465. this.canvas.height = sHeight;
  1466. this.shift = this.ctx.drawImage.bind(this.ctx, this.canvas, doShiftRight ? 1 : -1, 0);
  1467. this.fill = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, 1, sHeight, 0, 0, sWidth, sHeight);
  1468. this.take = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, 1, sHeight, doShiftRight ? 0 : sWidth - 1, 0, 1, sHeight);
  1469. this.giveEdge = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);
  1470. if (dy === 0) {
  1471. this.hasCorners = false;
  1472. return;
  1473. }
  1474. this.hasCorners = true;
  1475. const giveCorner0 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, 1, dx, 0, dWidth, dy);
  1476. const giveCorner1 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, sHeight - 1, sWidth, 1, dx, dy + dHeight, dWidth, dy);
  1477. this.giveCorners = () => {
  1478. giveCorner0();
  1479. giveCorner1();
  1480. };
  1481. }
  1482. }
  1483. class Base extends Sector {
  1484. setDimensions(doShiftDown, sWidth, sHeight, sx, sy, dx, dy, dWidth, dHeight) {
  1485. this.canvas.width = sWidth;
  1486. this.canvas.height = sHeight;
  1487. this.shift = this.ctx.drawImage.bind(this.ctx, this.canvas, 0, doShiftDown ? 1 : -1);
  1488. this.fill = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, sWidth, 1, 0, 0, sWidth, sHeight);
  1489. this.take = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, sWidth, 1, 0, doShiftDown ? 0 : sHeight - 1, sWidth, 1);
  1490. this.giveEdge = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);
  1491. if (dx === 0) {
  1492. this.hasCorners = false;
  1493. return;
  1494. }
  1495. this.hasCorners = true;
  1496. const giveCorner0 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, 1, sHeight, 0, dy, dx, dHeight);
  1497. const giveCorner1 = glowCtx.drawImage.bind(glowCtx, this.canvas, sWidth - 1, 0, 1, sHeight, dx + dWidth, dy, dx, dHeight);
  1498. this.giveCorners = () => {
  1499. giveCorner0();
  1500. giveCorner1();
  1501. };
  1502. }
  1503. setClipPath(points) {
  1504. this.clipPath = new Path2D();
  1505. this.clipPath.moveTo(...points[0]);
  1506. this.clipPath.lineTo(...points[1]);
  1507. this.clipPath.lineTo(...points[2]);
  1508. this.clipPath.closePath();
  1509. }
  1510. update(doFill) {
  1511. glowCtx.save();
  1512. glowCtx.clip(this.clipPath);
  1513. super.update(doFill);
  1514. glowCtx.restore();
  1515. }
  1516. }
  1517. const components = {
  1518. left: new Side(),
  1519. right: new Side(),
  1520. top: new Base(),
  1521. bottom: new Base(),
  1522. };
  1523. const setComponentDimensions = (sampleCount, size, isInset, doFlip) => {
  1524. const [croppedWidth, croppedHeight] = crop.getDimensions();
  1525. const halfCanvas = {x: Math.ceil(glowCanvas.width / 2), y: Math.ceil(glowCanvas.height / 2)};
  1526. const halfVideo = {x: croppedWidth / 2, y: croppedHeight / 2};
  1527. const dWidth = Math.ceil(Math.min(halfVideo.x, size));
  1528. const dHeight = Math.ceil(Math.min(halfVideo.y, size));
  1529. const [dWidthScale, dHeightScale, sideWidth, sideHeight] = isInset ?
  1530. [0, 0, videoCanvas.width / croppedWidth * glowCanvas.width, videoCanvas.height / croppedHeight * glowCanvas.height] :
  1531. [halfCanvas.x - halfVideo.x, halfCanvas.y - halfVideo.y, croppedWidth, croppedHeight];
  1532. components.left.setDimensions(!doFlip, sampleCount, videoCanvas.height, 0, 0, 0, dHeightScale, dWidth, sideHeight);
  1533. components.right.setDimensions(doFlip, sampleCount, videoCanvas.height, videoCanvas.width - 1, 0, glowCanvas.width - dWidth, dHeightScale, dWidth, sideHeight);
  1534. components.top.setDimensions(!doFlip, videoCanvas.width, sampleCount, 0, 0, dWidthScale, 0, sideWidth, dHeight);
  1535. components.top.setClipPath([[0, 0], [halfCanvas.x, halfCanvas.y], [glowCanvas.width, 0]]);
  1536. components.bottom.setDimensions(doFlip, videoCanvas.width, sampleCount, 0, videoCanvas.height - 1, dWidthScale, glowCanvas.height - dHeight, sideWidth, dHeight);
  1537. components.bottom.setClipPath([[0, glowCanvas.height], [halfCanvas.x, halfCanvas.y], [glowCanvas.width, glowCanvas.height]]);
  1538. };
  1539. class Instance {
  1540. constructor() {
  1541. const {filter, sampleCount, size, end, doFlip} = $config.get().glow;
  1542. // Setup canvases
  1543. glowCanvas.style.setProperty('filter', filter);
  1544. [glowCanvas.width, glowCanvas.height] = crop.getDimensions().map((dimension) => dimension * end);
  1545. glowCanvas.style.setProperty('left', `${crop.left * 100 + (1 - end) * (1 - crop.left - crop.right) * 50}%`);
  1546. glowCanvas.style.setProperty('top', `${crop.top * 100 + (1 - end) * (1 - crop.top - crop.bottom) * 50}%`);
  1547. [videoCanvas.width, videoCanvas.height] = crop.getDimensions(video.videoWidth, video.videoHeight);
  1548. setComponentDimensions(sampleCount, size, end <= 1, doFlip);
  1549. this.update(true);
  1550. }
  1551. update(doFill = false) {
  1552. videoCtx.drawImage(
  1553. video,
  1554. crop.left * video.videoWidth,
  1555. crop.top * video.videoHeight,
  1556. video.videoWidth * (1 - crop.left - crop.right),
  1557. video.videoHeight * (1 - crop.top - crop.bottom),
  1558. 0,
  1559. 0,
  1560. videoCanvas.width,
  1561. videoCanvas.height,
  1562. );
  1563. components.left.update(doFill);
  1564. components.right.update(doFill);
  1565. components.top.update(doFill);
  1566. components.bottom.update(doFill);
  1567. }
  1568. }
  1569. return new function () {
  1570. const container = document.createElement('div');
  1571. container.style.display = 'none';
  1572. container.appendChild(glowCanvas);
  1573. containers.background.appendChild(container);
  1574. this.isHidden = false;
  1575. let instance, startCopyLoop, stopCopyLoop;
  1576. const play = () => {
  1577. if (!video.paused && !this.isHidden && !enabler.isHidingGlow) {
  1578. startCopyLoop?.();
  1579. }
  1580. };
  1581. const fill = () => {
  1582. if (!this.isHidden) {
  1583. instance.update(true);
  1584. }
  1585. };
  1586. const handleVisibilityChange = () => {
  1587. if (document.hidden) {
  1588. stopCopyLoop();
  1589. } else {
  1590. play();
  1591. }
  1592. };
  1593. this.handleSizeChange = () => {
  1594. instance = new Instance();
  1595. };
  1596. // set up pausing if glow isn't visible
  1597. this.handleViewChange = (() => {
  1598. const cache = new Cache(rotation, zoom);
  1599. let cornerTop, cornerBottom;
  1600. return (doForce = false) => {
  1601. if (doForce || cache.isStale()) {
  1602. const viewportX = viewport.clientWidth / 2 / zoom.value;
  1603. const viewportY = viewport.clientHeight / 2 / zoom.value;
  1604. const angle = PI_HALVES[0] - rotation.value;
  1605. cornerTop = getGenericRotated(viewportX, viewportY, angle);
  1606. cornerBottom = getGenericRotated(viewportX, -viewportY, angle);
  1607. cornerTop.x = Math.abs(cornerTop.x);
  1608. cornerTop.y = Math.abs(cornerTop.y);
  1609. cornerBottom.x = Math.abs(cornerBottom.x);
  1610. cornerBottom.y = Math.abs(cornerBottom.y);
  1611. }
  1612. const videoX = Math.abs(position.x) * video.clientWidth;
  1613. const videoY = Math.abs(position.y) * video.clientHeight;
  1614. for (const corner of [cornerTop, cornerBottom]) {
  1615. if (
  1616. videoX + corner.x > (0.5 - crop.right) * video.clientWidth + 1
  1617. || videoX - corner.x < (crop.left - 0.5) * video.clientWidth - 1
  1618. || videoY + corner.y > (0.5 - crop.top) * video.clientHeight + 1
  1619. || videoY - corner.y < (crop.bottom - 0.5) * video.clientHeight - 1
  1620. ) {
  1621. // fill if newly visible
  1622. if (this.isHidden) {
  1623. instance?.update(true);
  1624. }
  1625. this.isHidden = false;
  1626. glowCanvas.style.removeProperty('visibility');
  1627. play();
  1628. return;
  1629. }
  1630. }
  1631. this.isHidden = true;
  1632. glowCanvas.style.visibility = 'hidden';
  1633. stopCopyLoop?.();
  1634. };
  1635. })();
  1636. const loop = {};
  1637. this.start = () => {
  1638. const config = $config.get().glow;
  1639. if (!config) {
  1640. return;
  1641. }
  1642. if (!enabler.isHidingGlow) {
  1643. container.style.removeProperty('display');
  1644. }
  1645. // todo handle this?
  1646. if (crop.left + crop.right >= 1 || crop.top + crop.bottom >= 1) {
  1647. return;
  1648. }
  1649. let loopId = -1;
  1650. if (loop.interval !== config.interval || loop.fps !== config.fps) {
  1651. loop.interval = config.interval;
  1652. loop.fps = config.fps;
  1653. loop.wasSlow = false;
  1654. loop.throttleCount = 0;
  1655. }
  1656. stopCopyLoop = () => ++loopId;
  1657. instance = new Instance();
  1658. startCopyLoop = async () => {
  1659. const id = ++loopId;
  1660. await new Promise((resolve) => {
  1661. window.setTimeout(resolve, config.interval);
  1662. });
  1663. while (id === loopId) {
  1664. const startTime = Date.now();
  1665. instance.update();
  1666. const delay = loop.interval - (Date.now() - startTime);
  1667. if (delay <= 0) {
  1668. if (loop.wasSlow) {
  1669. loop.interval = 1000 / (loop.fps - ++loop.throttleCount);
  1670. }
  1671. loop.wasSlow = !loop.wasSlow;
  1672. continue;
  1673. }
  1674. if (delay > 2 && loop.throttleCount > 0) {
  1675. console.warn(`[${GM.info.script.name}] Glow update frequency reduced from ${loop.fps} hertz to ${loop.fps - loop.throttleCount} hertz due to poor performance.`);
  1676. loop.fps -= loop.throttleCount;
  1677. loop.throttleCount = 0;
  1678. }
  1679. loop.wasSlow = false;
  1680. await new Promise((resolve) => {
  1681. window.setTimeout(resolve, delay);
  1682. });
  1683. }
  1684. };
  1685. play();
  1686. video.addEventListener('pause', stopCopyLoop);
  1687. video.addEventListener('play', play);
  1688. video.addEventListener('seeked', fill);
  1689. document.addEventListener('visibilitychange', handleVisibilityChange);
  1690. };
  1691. const priorCrop = {};
  1692. this.hide = () => {
  1693. Object.assign(priorCrop, crop);
  1694. stopCopyLoop?.();
  1695. container.style.display = 'none';
  1696. };
  1697. this.show = () => {
  1698. if (Object.entries(priorCrop).some(([edge, value]) => crop[edge] !== value)) {
  1699. this.reset();
  1700. } else {
  1701. play();
  1702. }
  1703. container.style.removeProperty('display');
  1704. };
  1705. this.stop = () => {
  1706. this.hide();
  1707. video.removeEventListener('pause', stopCopyLoop);
  1708. video.removeEventListener('play', play);
  1709. video.removeEventListener('seeked', fill);
  1710. document.removeEventListener('visibilitychange', handleVisibilityChange);
  1711. startCopyLoop = undefined;
  1712. stopCopyLoop = undefined;
  1713. };
  1714. this.reset = () => {
  1715. this.stop();
  1716. this.start();
  1717. };
  1718. }();
  1719. })();
  1720.  
  1721. const actions = (() => {
  1722. const drag = (event, clickCallback, moveCallback, target = video) => new Promise((resolve) => {
  1723. event.stopImmediatePropagation();
  1724. event.preventDefault();
  1725. // window blur events don't fire if devtools is open
  1726. stopDrag?.();
  1727. target.setPointerCapture(event.pointerId);
  1728. css.tag(enabler.CLASS_DRAGGING);
  1729. const clickDisallowListener = ({clientX, clientY}) => {
  1730. const {clickCutoff} = $config.get();
  1731. const distance = Math.abs(event.clientX - clientX) + Math.abs(event.clientY - clientY);
  1732. if (distance >= clickCutoff) {
  1733. target.removeEventListener('pointermove', clickDisallowListener);
  1734. target.removeEventListener('pointerup', clickCallback);
  1735. }
  1736. };
  1737. if (clickCallback) {
  1738. target.addEventListener('pointermove', clickDisallowListener);
  1739. target.addEventListener('pointerup', clickCallback, {once: true});
  1740. }
  1741. const doubleClickListener = (event) => {
  1742. event.stopImmediatePropagation();
  1743. };
  1744. target.addEventListener('pointermove', moveCallback);
  1745. viewport.parentElement.addEventListener('dblclick', doubleClickListener, true);
  1746. stopDrag = () => {
  1747. css.tag(enabler.CLASS_DRAGGING, false);
  1748. target.removeEventListener('pointermove', moveCallback);
  1749. if (clickCallback) {
  1750. target.removeEventListener('pointermove', clickDisallowListener);
  1751. target.removeEventListener('pointerup', clickCallback);
  1752. }
  1753. // delay removing listeners for events that happen after pointerup
  1754. window.setTimeout(() => {
  1755. viewport.parentElement.removeEventListener('dblclick', doubleClickListener, true);
  1756. document.removeEventListener('click', clickListener, true);
  1757. }, 0);
  1758. window.removeEventListener('blur', stopDrag);
  1759. target.removeEventListener('pointerup', stopDrag);
  1760. target.releasePointerCapture(event.pointerId);
  1761. stopDrag = undefined;
  1762. enabler.handleChange();
  1763. resolve();
  1764. };
  1765. window.addEventListener('blur', stopDrag);
  1766. target.addEventListener('pointerup', stopDrag);
  1767. const clickListener = (event) => {
  1768. event.stopImmediatePropagation();
  1769. event.preventDefault();
  1770. };
  1771. document.addEventListener('click', clickListener, true);
  1772. document.addEventListener('click', clickListener, true);
  1773. });
  1774. const getOnScroll = (() => {
  1775. // https://stackoverflow.com/a/30134826
  1776. const multipliers = [1, 40, 800];
  1777. return (callback) => (event) => {
  1778. event.stopImmediatePropagation();
  1779. event.preventDefault();
  1780. if (event.deltaY !== 0) {
  1781. callback(event.deltaY * multipliers[event.deltaMode]);
  1782. }
  1783. };
  1784. })();
  1785. const addListeners = ({onMouseDown, onRightClick, onScroll}, doAdd = true) => {
  1786. const property = `${doAdd ? 'add' : 'remove'}EventListener`;
  1787. altTarget[property]('pointerdown', onMouseDown);
  1788. altTarget[property]('contextmenu', onRightClick, true);
  1789. altTarget[property]('wheel', onScroll);
  1790. };
  1791. return {
  1792. crop: new function () {
  1793. let top = 0, right = 0, bottom = 0, left = 0, handle;
  1794. const values = {};
  1795. Object.defineProperty(values, 'top', {get: () => top, set: (value) => top = value});
  1796. Object.defineProperty(values, 'right', {get: () => right, set: (value) => right = value});
  1797. Object.defineProperty(values, 'bottom', {get: () => bottom, set: (value) => bottom = value});
  1798. Object.defineProperty(values, 'left', {get: () => left, set: (value) => left = value});
  1799. class Button {
  1800. // allowance for rounding errors
  1801. static ALLOWANCE_HANDLE = 0.0001;
  1802. static CLASS_HANDLE = 'viewfind-crop-handle';
  1803. static CLASS_EDGES = {
  1804. left: 'viewfind-crop-left',
  1805. top: 'viewfind-crop-top',
  1806. right: 'viewfind-crop-right',
  1807. bottom: 'viewfind-crop-bottom',
  1808. };
  1809. static OPPOSITES = {
  1810. left: 'right',
  1811. right: 'left',
  1812. top: 'bottom',
  1813. bottom: 'top',
  1814. };
  1815. callbacks = [];
  1816. element = document.createElement('div');
  1817. constructor(...edges) {
  1818. this.edges = edges;
  1819. this.isHandle = true;
  1820. this.element.style.position = 'absolute';
  1821. this.element.style.pointerEvents = 'all';
  1822. for (const edge of edges) {
  1823. this.element.style[edge] = '0';
  1824. this.element.classList.add(Button.CLASS_EDGES[edge]);
  1825. this.element.style.setProperty(`border-${Button.OPPOSITES[edge]}-width`, '1px');
  1826. }
  1827. this.element.addEventListener('contextmenu', (event) => {
  1828. event.stopPropagation();
  1829. event.preventDefault();
  1830. this.reset(false);
  1831. });
  1832. this.element.addEventListener('pointerdown', (() => {
  1833. const clickListener = ({offsetX, offsetY, target}) => {
  1834. this.set({
  1835. width: (this.edges.includes('left') ? offsetX : target.clientWidth - offsetX) / video.clientWidth,
  1836. height: (this.edges.includes('top') ? offsetY : target.clientHeight - offsetY) / video.clientHeight,
  1837. }, false);
  1838. };
  1839. const getDragListener = (event, target) => {
  1840. const getWidth = (() => {
  1841. if (this.edges.includes('left')) {
  1842. const position = this.element.clientWidth - event.offsetX;
  1843. return ({offsetX}) => offsetX + position;
  1844. }
  1845. const position = target.offsetWidth + event.offsetX;
  1846. return ({offsetX}) => position - offsetX;
  1847. })();
  1848. const getHeight = (() => {
  1849. if (this.edges.includes('top')) {
  1850. const position = this.element.clientHeight - event.offsetY;
  1851. return ({offsetY}) => offsetY + position;
  1852. }
  1853. const position = target.offsetHeight + event.offsetY;
  1854. return ({offsetY}) => position - offsetY;
  1855. })();
  1856. return (event) => {
  1857. this.set({
  1858. width: getWidth(event) / video.clientWidth,
  1859. height: getHeight(event) / video.clientHeight,
  1860. });
  1861. };
  1862. };
  1863. return async (event) => {
  1864. if (event.buttons === 1) {
  1865. const target = this.element.parentElement;
  1866. if (this.isHandle) {
  1867. this.setPanel();
  1868. }
  1869. await drag(event, clickListener, getDragListener(event, target), target);
  1870. this.updateCounterpart();
  1871. }
  1872. };
  1873. })());
  1874. }
  1875. notify() {
  1876. for (const callback of this.callbacks) {
  1877. callback();
  1878. }
  1879. }
  1880. set isHandle(value) {
  1881. this._isHandle = value;
  1882. this.element.classList[value ? 'add' : 'remove'](Button.CLASS_HANDLE);
  1883. }
  1884. get isHandle() {
  1885. return this._isHandle;
  1886. }
  1887. reset() {
  1888. this.isHandle = true;
  1889. for (const edge of this.edges) {
  1890. values[edge] = 0;
  1891. }
  1892. }
  1893. }
  1894. class EdgeButton extends Button {
  1895. constructor(edge) {
  1896. super(edge);
  1897. this.edge = edge;
  1898. }
  1899. updateCounterpart() {
  1900. if (this.counterpart.isHandle) {
  1901. this.counterpart.setHandle();
  1902. }
  1903. }
  1904. setCrop(value = 0) {
  1905. values[this.edge] = value;
  1906. }
  1907. setPanel() {
  1908. this.isHandle = false;
  1909. this.setCrop(handle);
  1910. this.setHandle();
  1911. }
  1912. }
  1913. class SideButton extends EdgeButton {
  1914. flow() {
  1915. let size = 1;
  1916. if (top <= Button.ALLOWANCE_HANDLE) {
  1917. size -= handle;
  1918. this.element.style.top = `${handle * 100}%`;
  1919. } else {
  1920. size -= top;
  1921. this.element.style.top = `${top * 100}%`;
  1922. }
  1923. if (bottom <= Button.ALLOWANCE_HANDLE) {
  1924. size -= handle;
  1925. } else {
  1926. size -= bottom;
  1927. }
  1928. this.element.style.height = `${Math.max(0, size * 100)}%`;
  1929. }
  1930. setBounds(counterpart, components) {
  1931. this.counterpart = components[counterpart];
  1932. components.top.callbacks.push(() => {
  1933. this.flow();
  1934. });
  1935. components.bottom.callbacks.push(() => {
  1936. this.flow();
  1937. });
  1938. }
  1939. setHandle(doNotify = true) {
  1940. this.element.style.width = `${Math.min(1 - values[this.counterpart.edge], handle) * 100}%`;
  1941. if (doNotify) {
  1942. this.notify();
  1943. }
  1944. }
  1945. set({width}, doUpdateCounterpart = true) {
  1946. if (this.isHandle !== (this.isHandle = width <= Button.ALLOWANCE_HANDLE)) {
  1947. this.flow();
  1948. }
  1949. if (doUpdateCounterpart) {
  1950. this.updateCounterpart();
  1951. }
  1952. if (this.isHandle) {
  1953. this.setCrop();
  1954. this.setHandle();
  1955. return;
  1956. }
  1957. const size = Math.min(1 - values[this.counterpart.edge], width);
  1958. this.setCrop(size);
  1959. this.element.style.width = `${size * 100}%`;
  1960. this.notify();
  1961. }
  1962. reset(isGeneral = true) {
  1963. super.reset();
  1964. if (isGeneral) {
  1965. this.element.style.top = `${handle * 100}%`;
  1966. this.element.style.height = `${(0.5 - handle) * 200}%`;
  1967. this.element.style.width = `${handle * 100}%`;
  1968. return;
  1969. }
  1970. this.flow();
  1971. this.setHandle();
  1972. this.updateCounterpart();
  1973. }
  1974. }
  1975. class BaseButton extends EdgeButton {
  1976. flow() {
  1977. let size = 1;
  1978. if (left <= Button.ALLOWANCE_HANDLE) {
  1979. size -= handle;
  1980. this.element.style.left = `${handle * 100}%`;
  1981. } else {
  1982. size -= left;
  1983. this.element.style.left = `${left * 100}%`;
  1984. }
  1985. if (right <= Button.ALLOWANCE_HANDLE) {
  1986. size -= handle;
  1987. } else {
  1988. size -= right;
  1989. }
  1990. this.element.style.width = `${Math.max(0, size) * 100}%`;
  1991. }
  1992. setBounds(counterpart, components) {
  1993. this.counterpart = components[counterpart];
  1994. components.left.callbacks.push(() => {
  1995. this.flow();
  1996. });
  1997. components.right.callbacks.push(() => {
  1998. this.flow();
  1999. });
  2000. }
  2001. setHandle(doNotify = true) {
  2002. this.element.style.height = `${Math.min(1 - values[this.counterpart.edge], handle) * 100}%`;
  2003. if (doNotify) {
  2004. this.notify();
  2005. }
  2006. }
  2007. set({height}, doUpdateCounterpart = false) {
  2008. if (this.isHandle !== (this.isHandle = height <= Button.ALLOWANCE_HANDLE)) {
  2009. this.flow();
  2010. }
  2011. if (doUpdateCounterpart) {
  2012. this.updateCounterpart();
  2013. }
  2014. if (this.isHandle) {
  2015. this.setCrop();
  2016. this.setHandle();
  2017. return;
  2018. }
  2019. const size = Math.min(1 - values[this.counterpart.edge], height);
  2020. this.setCrop(size);
  2021. this.element.style.height = `${size * 100}%`;
  2022. this.notify();
  2023. }
  2024. reset(isGeneral = true) {
  2025. super.reset();
  2026. if (isGeneral) {
  2027. this.element.style.left = `${handle * 100}%`;
  2028. this.element.style.width = `${(0.5 - handle) * 200}%`;
  2029. this.element.style.height = `${handle * 100}%`;
  2030. return;
  2031. }
  2032. this.flow();
  2033. this.setHandle();
  2034. this.updateCounterpart();
  2035. }
  2036. }
  2037. class CornerButton extends Button {
  2038. static CLASS_NAME = 'viewfind-crop-corner';
  2039. constructor(sectors, ...edges) {
  2040. super(...edges);
  2041. this.element.classList.add(CornerButton.CLASS_NAME);
  2042. this.sectors = sectors;
  2043. for (const sector of sectors) {
  2044. sector.callbacks.push(this.flow.bind(this));
  2045. }
  2046. }
  2047. flow() {
  2048. let isHandle = true;
  2049. if (this.sectors[0].isHandle) {
  2050. this.element.style.width = `${Math.min(1 - values[this.sectors[0].counterpart.edge], handle) * 100}%`;
  2051. } else {
  2052. this.element.style.width = `${values[this.edges[0]] * 100}%`;
  2053. isHandle = false;
  2054. }
  2055. if (this.sectors[1].isHandle) {
  2056. this.element.style.height = `${Math.min(1 - values[this.sectors[1].counterpart.edge], handle) * 100}%`;
  2057. } else {
  2058. this.element.style.height = `${values[this.edges[1]] * 100}%`;
  2059. isHandle = false;
  2060. }
  2061. this.isHandle = isHandle;
  2062. }
  2063. updateCounterpart() {
  2064. for (const sector of this.sectors) {
  2065. sector.updateCounterpart();
  2066. }
  2067. }
  2068. set(size) {
  2069. for (const sector of this.sectors) {
  2070. sector.set(size);
  2071. }
  2072. }
  2073. reset(isGeneral = true) {
  2074. this.isHandle = true;
  2075. this.element.style.width = `${handle * 100}%`;
  2076. this.element.style.height = `${handle * 100}%`;
  2077. if (isGeneral) {
  2078. return;
  2079. }
  2080. for (const sector of this.sectors) {
  2081. sector.reset(false);
  2082. }
  2083. }
  2084. setPanel() {
  2085. for (const sector of this.sectors) {
  2086. sector.setPanel();
  2087. }
  2088. }
  2089. }
  2090. this.CODE = 'crop';
  2091. this.CLASS_ABLE = 'viewfind-action-able-crop';
  2092. const container = document.createElement('div');
  2093. // todo ditch the containers object
  2094. container.style.width = container.style.height = 'inherit';
  2095. containers.foreground.append(container);
  2096. this.reset = () => {
  2097. for (const component of Object.values(this.components)) {
  2098. component.reset(true);
  2099. }
  2100. };
  2101. this.onRightClick = (event) => {
  2102. if (event.target.parentElement.id === container.id) {
  2103. return;
  2104. }
  2105. event.stopPropagation();
  2106. event.preventDefault();
  2107. if (stopDrag) {
  2108. return;
  2109. }
  2110. this.reset();
  2111. };
  2112. this.onScroll = getOnScroll((distance) => {
  2113. const increment = distance * $config.get().speeds.crop / zoom.value;
  2114. this.components.top.set({height: top + Math.min((1 - top - bottom) / 2, increment)});
  2115. this.components.left.set({width: left + Math.min((1 - left - right) / 2, increment)});
  2116. this.components.bottom.set({height: bottom + increment});
  2117. this.components.right.set({width: right + increment});
  2118. });
  2119. this.onMouseDown = (() => {
  2120. const getDragListener = () => {
  2121. const multiplier = $config.get().multipliers.crop;
  2122. const setX = ((right, left, change) => {
  2123. const clamped = Math.max(-left, Math.min(right, change * multiplier / video.clientWidth));
  2124. this.components.left.set({width: left + clamped});
  2125. this.components.right.set({width: right - clamped});
  2126. }).bind(undefined, right, left);
  2127. const setY = ((top, bottom, change) => {
  2128. const clamped = Math.max(-top, Math.min(bottom, change * multiplier / video.clientHeight));
  2129. this.components.top.set({height: top + clamped});
  2130. this.components.bottom.set({height: bottom - clamped});
  2131. }).bind(undefined, top, bottom);
  2132. let priorEvent;
  2133. return ({offsetX, offsetY}) => {
  2134. if (!priorEvent) {
  2135. priorEvent = {offsetX, offsetY};
  2136. return;
  2137. }
  2138. setX(offsetX - priorEvent.offsetX);
  2139. setY(offsetY - priorEvent.offsetY);
  2140. };
  2141. };
  2142. const clickListener = () => {
  2143. zoom.value = zoom.getFit(1 - left - right, 1 - top - bottom);
  2144. zoom.constrain();
  2145. position.x = (left - right) / 2;
  2146. position.y = (bottom - top) / 2;
  2147. position.constrain();
  2148. };
  2149. return (event) => {
  2150. if (event.buttons === 1) {
  2151. drag(event, clickListener, getDragListener(), container);
  2152. }
  2153. };
  2154. })();
  2155. this.components = {
  2156. top: new BaseButton('top'),
  2157. right: new SideButton('right'),
  2158. bottom: new BaseButton('bottom'),
  2159. left: new SideButton('left'),
  2160. };
  2161. this.components.top.setBounds('bottom', this.components);
  2162. this.components.right.setBounds('left', this.components);
  2163. this.components.bottom.setBounds('top', this.components);
  2164. this.components.left.setBounds('right', this.components);
  2165. this.components.topLeft = new CornerButton([this.components.left, this.components.top], 'left', 'top');
  2166. this.components.topRight = new CornerButton([this.components.right, this.components.top], 'right', 'top');
  2167. this.components.bottomLeft = new CornerButton([this.components.left, this.components.bottom], 'left', 'bottom');
  2168. this.components.bottomRight = new CornerButton([this.components.right, this.components.bottom], 'right', 'bottom');
  2169. container.append(...Object.values(this.components).map(({element}) => element));
  2170. this.set = ({top, right, bottom, left}) => {
  2171. this.components.top.set({height: top});
  2172. this.components.right.set({width: right});
  2173. this.components.bottom.set({height: bottom});
  2174. this.components.left.set({width: left});
  2175. };
  2176. this.onInactive = () => {
  2177. addListeners(this, false);
  2178. if (crop.left === left && crop.top === top && crop.right === right && crop.bottom === bottom) {
  2179. return;
  2180. }
  2181. crop.left = left;
  2182. crop.top = top;
  2183. crop.right = right;
  2184. crop.bottom = bottom;
  2185. crop.apply();
  2186. };
  2187. this.onActive = () => {
  2188. const config = $config.get().crop;
  2189. handle = config.handle / Math.max(zoom.value, 1);
  2190. for (const component of [this.components.top, this.components.bottom, this.components.left, this.components.right]) {
  2191. if (component.isHandle) {
  2192. component.setHandle();
  2193. }
  2194. }
  2195. crop.reveal();
  2196. addListeners(this);
  2197. if (!enabler.isHidingGlow) {
  2198. glow.handleViewChange();
  2199. glow.reset();
  2200. }
  2201. };
  2202. const draggingSelector = css.getSelector(enabler.CLASS_DRAGGING);
  2203. this.updateConfig = (() => {
  2204. const rule = new css.Toggleable();
  2205. return () => {
  2206. // set handle size
  2207. for (const button of [this.components.left, this.components.top, this.components.right, this.components.bottom]) {
  2208. if (button.isHandle) {
  2209. button.setHandle();
  2210. }
  2211. }
  2212. rule.remove();
  2213. const {colour} = $config.get().crop;
  2214. const {id} = container;
  2215. rule.add(`#${id}>:hover.${Button.CLASS_HANDLE},#${id}>:not(.${Button.CLASS_HANDLE})`, ['background-color', colour.fill]);
  2216. rule.add(`#${id}>*`, ['border-color', colour.border]);
  2217. rule.add(`#${id}:not(${draggingSelector} *)>:not(:hover)`, ['filter', `drop-shadow(${colour.shadow} 0 0 1px)`]);
  2218. };
  2219. })();
  2220. $config.ready.then(() => {
  2221. this.updateConfig();
  2222. });
  2223. container.id = 'viewfind-crop-container';
  2224. (() => {
  2225. const {id} = container;
  2226. css.add(`${css.getSelector(enabler.CLASS_DRAGGING)} #${id}`, ['cursor', 'grabbing']);
  2227. css.add(`${css.getSelector(enabler.CLASS_ABLE)} #${id}`, ['cursor', 'grab']);
  2228. css.add(`#${id}>:not(${draggingSelector} .${Button.CLASS_HANDLE})`, ['border-style', 'solid']);
  2229. css.add(`${draggingSelector} #${id}>.${Button.CLASS_HANDLE}`, ['filter', 'none']);
  2230. for (const [side, sideClass] of Object.entries(Button.CLASS_EDGES)) {
  2231. css.add(
  2232. `${draggingSelector} #${id}>.${sideClass}.${Button.CLASS_HANDLE}~.${sideClass}.${CornerButton.CLASS_NAME}`,
  2233. [`border-${CornerButton.OPPOSITES[side]}-style`, 'none'],
  2234. ['filter', 'none'],
  2235. );
  2236. // in fullscreen, 16:9 videos get an offsetLeft of 1px on my 16:9 monitor
  2237. // I'm extending buttons by 1px so that they reach the edge of screens like mine at default zoom
  2238. css.add(`#${id}>.${sideClass}`, [`margin-${side}`, '-1px'], [`padding-${side}`, '1px']);
  2239. }
  2240. css.add(`#${id}:not(.${this.CLASS_ABLE} *)`, ['display', 'none']);
  2241. })();
  2242. }(),
  2243. pan: new function () {
  2244. this.CODE = 'pan';
  2245. this.CLASS_ABLE = 'viewfind-action-able-pan';
  2246. this.onActive = () => {
  2247. this.updateCrosshair();
  2248. addListeners(this);
  2249. };
  2250. this.onInactive = () => {
  2251. addListeners(this, false);
  2252. };
  2253. this.updateCrosshair = (() => {
  2254. const getRoundedString = (number, decimal = 2) => {
  2255. const raised = `${Math.round(number * Math.pow(10, decimal))}`.padStart(decimal + 1, '0');
  2256. return `${raised.substr(0, raised.length - decimal)}.${raised.substr(raised.length - decimal)}`;
  2257. };
  2258. const getSigned = (ratio) => {
  2259. const percent = Math.round(ratio * 100);
  2260. if (percent <= 0) {
  2261. return `${percent}`;
  2262. }
  2263. return `+${percent}`;
  2264. };
  2265. return () => {
  2266. crosshair.text.innerText = `${getRoundedString(zoom.value)}×\n${getSigned(position.x)}%\n${getSigned(position.y)}%`;
  2267. };
  2268. })();
  2269. this.onScroll = getOnScroll((distance) => {
  2270. const increment = distance * $config.get().speeds.zoom;
  2271. if (increment > 0) {
  2272. zoom.value *= 1 + increment;
  2273. } else {
  2274. zoom.value /= 1 - increment;
  2275. }
  2276. zoom.constrain();
  2277. position.constrain();
  2278. this.updateCrosshair();
  2279. });
  2280. this.onRightClick = (event) => {
  2281. event.stopImmediatePropagation();
  2282. event.preventDefault();
  2283. if (stopDrag) {
  2284. return;
  2285. }
  2286. position.x = position.y = 0;
  2287. zoom.value = 1;
  2288. position.apply();
  2289. zoom.constrain();
  2290. this.updateCrosshair();
  2291. };
  2292. this.onMouseDown = (() => {
  2293. const getDragListener = () => {
  2294. const {multipliers} = $config.get();
  2295. let priorEvent;
  2296. const change = {x: 0, y: 0};
  2297. return ({offsetX, offsetY}) => {
  2298. if (priorEvent) {
  2299. change.x = (priorEvent.offsetX + change.x - offsetX) * multipliers.pan;
  2300. change.y = (priorEvent.offsetY - change.y - offsetY) * -multipliers.pan;
  2301. position.x += change.x / video.clientWidth;
  2302. position.y += change.y / video.clientHeight;
  2303. position.constrain();
  2304. this.updateCrosshair();
  2305. }
  2306. // events in firefox seem to lose their data after finishing propogation
  2307. // so assigning the whole event doesn't work
  2308. priorEvent = {offsetX, offsetY};
  2309. };
  2310. };
  2311. const clickListener = (event) => {
  2312. position.x = event.offsetX / video.clientWidth - 0.5;
  2313. // Y increases moving down the page
  2314. // I flip that to make trigonometry easier
  2315. position.y = -event.offsetY / video.clientHeight + 0.5;
  2316. position.constrain(true);
  2317. this.updateCrosshair();
  2318. };
  2319. return (event) => {
  2320. if (event.buttons === 1) {
  2321. drag(event, clickListener, getDragListener());
  2322. }
  2323. };
  2324. })();
  2325. }(),
  2326. rotate: new function () {
  2327. this.CODE = 'rotate';
  2328. this.CLASS_ABLE = 'viewfind-action-able-rotate';
  2329. this.onActive = () => {
  2330. this.updateCrosshair();
  2331. addListeners(this);
  2332. };
  2333. this.onInactive = () => {
  2334. addListeners(this, false);
  2335. };
  2336. this.updateCrosshair = () => {
  2337. const angle = PI_HALVES[0] - rotation.value;
  2338. crosshair.text.innerText = `${Math.floor((PI_HALVES[0] - rotation.value) / Math.PI * 180)}°\n${Math.round(angle / PI_HALVES[0]) % 4 * 90}°`;
  2339. };
  2340. this.onScroll = getOnScroll((distance) => {
  2341. rotation.value += distance * $config.get().speeds.rotate;
  2342. rotation.constrain();
  2343. zoom.constrain();
  2344. position.constrain();
  2345. this.updateCrosshair();
  2346. });
  2347. this.onRightClick = (event) => {
  2348. event.stopImmediatePropagation();
  2349. event.preventDefault();
  2350. if (stopDrag) {
  2351. return;
  2352. }
  2353. rotation.value = PI_HALVES[0];
  2354. rotation.apply();
  2355. zoom.constrain();
  2356. position.constrain();
  2357. this.updateCrosshair();
  2358. };
  2359. this.onMouseDown = (() => {
  2360. const getDragListener = () => {
  2361. const {multipliers} = $config.get();
  2362. const middleX = containers.tracker.clientWidth / 2;
  2363. const middleY = containers.tracker.clientHeight / 2;
  2364. const priorPosition = position.getValues();
  2365. const priorZoom = zoom.value;
  2366. let priorMouseTheta;
  2367. return (event) => {
  2368. const mouseTheta = getTheta(middleX, middleY, event.offsetX, event.offsetY);
  2369. if (priorMouseTheta === undefined) {
  2370. priorMouseTheta = mouseTheta;
  2371. return;
  2372. }
  2373. position.x = priorPosition.x;
  2374. position.y = priorPosition.y;
  2375. zoom.value = priorZoom;
  2376. rotation.value += (priorMouseTheta - mouseTheta) * multipliers.rotate;
  2377. rotation.constrain();
  2378. zoom.constrain();
  2379. position.constrain();
  2380. this.updateCrosshair();
  2381. priorMouseTheta = mouseTheta;
  2382. };
  2383. };
  2384. const clickListener = () => {
  2385. rotation.value = Math.round(rotation.value / PI_HALVES[0]) * PI_HALVES[0];
  2386. rotation.constrain();
  2387. zoom.constrain();
  2388. position.constrain();
  2389. this.updateCrosshair();
  2390. };
  2391. return (event) => {
  2392. if (event.buttons === 1) {
  2393. drag(event, clickListener, getDragListener(), containers.tracker);
  2394. }
  2395. };
  2396. })();
  2397. }(),
  2398. configure: new function () {
  2399. this.CODE = 'config';
  2400. this.onActive = async () => {
  2401. await $config.edit();
  2402. updateConfigs();
  2403. viewport.focus();
  2404. glow.reset();
  2405. position.constrain();
  2406. zoom.constrain();
  2407. };
  2408. }(),
  2409. reset: new function () {
  2410. this.CODE = 'reset';
  2411. this.onActive = () => {
  2412. if (this.prior) {
  2413. zoom.value = this.prior.zoom;
  2414. rotation.value = this.prior.rotation;
  2415. Object.assign(position, this.prior.position);
  2416. Object.assign(crop, this.prior.crop);
  2417. actions.crop.set(this.prior.crop);
  2418. position.apply();
  2419. rotation.apply();
  2420. zoom.apply();
  2421. crop.apply();
  2422. return;
  2423. }
  2424. const prior = {
  2425. zoom: zoom.value,
  2426. rotation: rotation.value,
  2427. crop: crop.getValues(),
  2428. position: position.getValues(),
  2429. };
  2430. position.reset();
  2431. rotation.reset();
  2432. zoom.reset();
  2433. crop.reset();
  2434. glow.reset();
  2435. this.prior = prior;
  2436. };
  2437. }(),
  2438. };
  2439. })();
  2440.  
  2441. const crosshair = new function () {
  2442. this.container = document.createElement('div');
  2443. this.lines = {
  2444. horizontal: document.createElement('div'),
  2445. vertical: document.createElement('div'),
  2446. };
  2447. this.text = document.createElement('div');
  2448. const id = 'viewfind-crosshair';
  2449. this.container.id = id;
  2450. this.container.classList.add(CLASS_VIEWFINDER);
  2451. css.add(`#${id}:not(${css.getSelector(actions.pan.CLASS_ABLE)} *):not(${css.getSelector(actions.rotate.CLASS_ABLE)} *)`, ['display', 'none']);
  2452. this.lines.horizontal.style.position = this.lines.vertical.style.position = this.text.style.position = this.container.style.position = 'absolute';
  2453. this.lines.horizontal.style.top = '50%';
  2454. this.lines.horizontal.style.width = '100%';
  2455. this.lines.vertical.style.left = '50%';
  2456. this.lines.vertical.style.height = '100%';
  2457. this.text.style.userSelect = 'none';
  2458. this.container.style.top = '0';
  2459. this.container.style.width = '100%';
  2460. this.container.style.height = '100%';
  2461. this.container.style.pointerEvents = 'none';
  2462. this.container.append(this.lines.horizontal, this.lines.vertical);
  2463. this.clip = () => {
  2464. const {outer, inner, gap} = $config.get().crosshair;
  2465. const thickness = Math.max(inner, outer);
  2466. const halfWidth = viewport.clientWidth / 2;
  2467. const halfHeight = viewport.clientHeight / 2;
  2468. const halfGap = gap / 2;
  2469. const startInner = (thickness - inner) / 2;
  2470. const startOuter = (thickness - outer) / 2;
  2471. const endInner = thickness - startInner;
  2472. const endOuter = thickness - startOuter;
  2473. this.lines.horizontal.style.clipPath = 'path(\''
  2474. + `M0 ${startOuter}L${halfWidth - halfGap} ${startOuter}L${halfWidth - halfGap} ${startInner}L${halfWidth + halfGap} ${startInner}L${halfWidth + halfGap} ${startOuter}L${viewport.clientWidth} ${startOuter}`
  2475. + `L${viewport.clientWidth} ${endOuter}L${halfWidth + halfGap} ${endOuter}L${halfWidth + halfGap} ${endInner}L${halfWidth - halfGap} ${endInner}L${halfWidth - halfGap} ${endOuter}L0 ${endOuter}`
  2476. + 'Z\')';
  2477. this.lines.vertical.style.clipPath = 'path(\''
  2478. + `M${startOuter} 0L${startOuter} ${halfHeight - halfGap}L${startInner} ${halfHeight - halfGap}L${startInner} ${halfHeight + halfGap}L${startOuter} ${halfHeight + halfGap}L${startOuter} ${viewport.clientHeight}`
  2479. + `L${endOuter} ${viewport.clientHeight}L${endOuter} ${halfHeight + halfGap}L${endInner} ${halfHeight + halfGap}L${endInner} ${halfHeight - halfGap}L${endOuter} ${halfHeight - halfGap}L${endOuter} 0`
  2480. + 'Z\')';
  2481. };
  2482. this.updateConfig = (doClip = true) => {
  2483. const {colour, outer, inner, text} = $config.get().crosshair;
  2484. const thickness = Math.max(inner, outer);
  2485. this.container.style.filter = `drop-shadow(${colour.shadow} 0 0 1px)`;
  2486. this.lines.horizontal.style.translate = `0 -${thickness / 2}px`;
  2487. this.lines.vertical.style.translate = `-${thickness / 2}px 0`;
  2488. this.lines.horizontal.style.height = this.lines.vertical.style.width = `${thickness}px`;
  2489. this.lines.horizontal.style.backgroundColor = this.lines.vertical.style.backgroundColor = colour.fill;
  2490. if (text) {
  2491. this.text.style.color = colour.fill;
  2492. this.text.style.font = text.font;
  2493. this.text.style.left = `${text.position.x}%`;
  2494. this.text.style.top = `${text.position.y}%`;
  2495. this.text.style.transform = `translate(${text.translate.x}%,${text.translate.y}%) translate(${text.offset.x}px,${text.offset.y}px)`;
  2496. this.text.style.textAlign = text.align;
  2497. this.text.style.lineHeight = text.height;
  2498. this.container.append(this.text);
  2499. } else {
  2500. this.text.remove();
  2501. }
  2502. if (doClip) {
  2503. this.clip();
  2504. }
  2505. };
  2506. $config.ready.then(() => {
  2507. this.updateConfig(false);
  2508. });
  2509. }();
  2510.  
  2511. // ELEMENT CHANGE LISTENERS
  2512.  
  2513. const observer = new function () {
  2514. const onVideoRestart = () => {
  2515. start();
  2516. };
  2517. this.onNavigate = () => {
  2518. video.removeEventListener('play', onVideoRestart);
  2519. };
  2520. const onResolutionChange = () => {
  2521. glow.handleSizeChange?.();
  2522. };
  2523. const styleObserver = new MutationObserver((() => {
  2524. const properties = ['top', 'left', 'width', 'height', 'scale', 'rotate', 'translate'];
  2525. let priorStyle;
  2526. return () => {
  2527. // mousemove events on video with ctrlKey=true trigger this but have no effect
  2528. if (video.style.cssText === priorStyle) {
  2529. return;
  2530. }
  2531. priorStyle = video.style.cssText;
  2532. for (const property of properties) {
  2533. containers.background.style[property] = video.style[property];
  2534. containers.foreground.style[property] = video.style[property];
  2535. // cinematics doesn't exist for embedded vids
  2536. if (cinematics) {
  2537. cinematics.style[property] = video.style[property];
  2538. }
  2539. }
  2540. glow.handleViewChange();
  2541. };
  2542. })());
  2543. // video 'ended' event isn't fired on seeks directly to the video's end
  2544. const endObserver = new MutationObserver(() => {
  2545. if (!viewport.classList.contains('ended-mode')) {
  2546. return;
  2547. }
  2548. stop();
  2549. video.addEventListener('play', onVideoRestart, {once: true});
  2550. });
  2551. const videoObserver = new ResizeObserver(() => {
  2552. viewportAngles.set();
  2553. glow.handleSizeChange?.();
  2554. });
  2555. const viewportObserver = new ResizeObserver(() => {
  2556. viewportAngles.set();
  2557. crosshair.clip();
  2558. });
  2559. this.start = () => {
  2560. video.addEventListener('resize', onResolutionChange);
  2561. styleObserver.observe(video, {attributes: true, attributeFilter: ['style']});
  2562. endObserver.observe(viewport, {attributes: true, attributeFilter: ['class']});
  2563. viewportObserver.observe(viewport);
  2564. videoObserver.observe(video);
  2565. glow.handleViewChange();
  2566. };
  2567. this.stop = () => {
  2568. video.removeEventListener('resize', onResolutionChange);
  2569. styleObserver.disconnect();
  2570. endObserver.disconnect();
  2571. viewportObserver.disconnect();
  2572. videoObserver.disconnect();
  2573. };
  2574. }();
  2575.  
  2576. // NAVIGATION LISTENERS
  2577.  
  2578. const stop = () => {
  2579. if (stopped) {
  2580. return;
  2581. }
  2582. stopped = true;
  2583. enabler.stop();
  2584. stopDrag?.();
  2585. observer.stop();
  2586. containers.background.remove();
  2587. containers.foreground.remove();
  2588. containers.tracker.remove();
  2589. crosshair.container.remove();
  2590. glow.stop();
  2591. position.reset();
  2592. rotation.reset();
  2593. zoom.reset();
  2594. crop.reset();
  2595. };
  2596.  
  2597. const start = () => {
  2598. if (!stopped) {
  2599. return;
  2600. }
  2601. stopped = false;
  2602. observer.start();
  2603. glow.start();
  2604. viewport.append(containers.background, containers.foreground, containers.tracker, crosshair.container);
  2605. // User may have a static minimum zoom greater than 1
  2606. zoom.constrain();
  2607. enabler.handleChange();
  2608. };
  2609.  
  2610. const updateConfigs = () => {
  2611. ConfigCache.id++;
  2612. enabler.updateConfig();
  2613. actions.crop.updateConfig();
  2614. crosshair.updateConfig();
  2615. };
  2616.  
  2617. // LISTENER ASSIGNMENTS
  2618.  
  2619. // load & navigation
  2620. (() => {
  2621. const init = async () => {
  2622. video = document.querySelector(SELECTOR_VIDEO);
  2623. if (unsafeWindow.ytplayer?.bootstrapPlayerContainer?.contains(video)) {
  2624. // wait for the video to be moved to ytd-app
  2625. await new Promise((resolve) => {
  2626. new MutationObserver((changes, observer) => {
  2627. resolve();
  2628. observer.disconnect();
  2629. }).observe(unsafeWindow.ytplayer.bootstrapPlayerContainer, {childList: true});
  2630. });
  2631. }
  2632. viewport = video.parentElement.parentElement;
  2633. altTarget = viewport.parentElement;
  2634. cinematics = document.querySelector('#cinematics');
  2635. try {
  2636. await $config.ready;
  2637. } catch (error) {
  2638. if (!$config.reset || !window.confirm(`${error.message}\n\nWould you like to erase your data?`)) {
  2639. console.error(error);
  2640. return;
  2641. }
  2642. await $config.reset();
  2643. updateConfigs();
  2644. }
  2645. containers.foreground.style.zIndex = crosshair.container.style.zIndex = video.parentElement.computedStyleMap?.().get('z-index').value ?? 10;
  2646. crosshair.clip();
  2647. viewportAngles.set();
  2648. // wait for video dimensions for glow initialisation
  2649. if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
  2650. start();
  2651. }
  2652. video.addEventListener('loadedmetadata', () => start());
  2653. // hacky navigation listener
  2654. new MutationObserver((changes) => {
  2655. // avoid stopping on resolution change
  2656. if (changes.length > 1 || video.src === '') {
  2657. observer.onNavigate();
  2658. stop();
  2659. }
  2660. }).observe(video, {attributes: true, attributeFilter: ['src']});
  2661. };
  2662. if (unsafeWindow.ytPageType === 'watch' || isEmbed) {
  2663. init();
  2664. return;
  2665. }
  2666. const initListener = ({detail: {newPageType}}) => {
  2667. if (newPageType === 'ytd-watch-flexy') {
  2668. init();
  2669. document.body.removeEventListener('yt-page-type-changed', initListener);
  2670. }
  2671. };
  2672. document.body.addEventListener('yt-page-type-changed', initListener);
  2673. })();
  2674.  
  2675. // keyboard state change
  2676.  
  2677. document.addEventListener('keydown', ({code}) => {
  2678. if (enabler.toggled) {
  2679. enabler.keys[enabler.keys.has(code) ? 'delete' : 'add'](code);
  2680. enabler.handleChange();
  2681. } else if (!enabler.keys.has(code)) {
  2682. enabler.keys.add(code);
  2683. enabler.handleChange();
  2684. }
  2685. });
  2686.  
  2687. document.addEventListener('keyup', ({code}) => {
  2688. if (enabler.toggled) {
  2689. return;
  2690. }
  2691. if (enabler.keys.has(code)) {
  2692. enabler.keys.delete(code);
  2693. enabler.handleChange();
  2694. }
  2695. });
  2696.  
  2697. window.addEventListener('blur', () => {
  2698. if (enabler.toggled) {
  2699. stopDrag?.();
  2700. } else {
  2701. enabler.keys.clear();
  2702. enabler.handleChange();
  2703. }
  2704. });
  2705. })();