YouTube View Controls

Zoom, rotate & crop YouTube videos.

当前为 2025-01-19 提交的版本,查看 最新版本

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