YouTube Viewfinding

Zoom, rotate & crop YouTube videos

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