YouTube View Controls

Zoom, rotate & crop YouTube videos.

目前為 2025-01-12 提交的版本,檢視 最新版本

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