YouTube Viewfinding

Zoom, rotate & crop YouTube videos

当前为 2025-05-11 提交的版本,查看 最新版本

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