YouTube Viewfinding

Zoom, rotate & crop YouTube videos

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

QingJ © 2025

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