- // ==UserScript==
- // @name YouTube View Controls
- // @version 0.0
- // @description Zoom, rotate & crop YouTube videos.
- // @author Callum Latham
- // @namespace https://greasyfork.org/users/696211-ctl2
- // @license MIT
- // @match *://www.youtube.com/*
- // @match *://youtube.com/*
- // @exclude *://www.youtube.com/embed/*
- // @exclude *://youtube.com/embed/*
- // @require https://update.greasyfork.org/scripts/446506/1518983/%24Config.js
- // @grant GM.setValue
- // @grant GM.getValue
- // @grant GM.deleteValue
- // ==/UserScript==
-
- /* global $Config */
-
- // Don't run in frames (e.g. stream chat frame)
- if (window.parent !== window) {
- return;
- }
-
- const $config = new $Config(
- 'YTVC_TREE',
- (() => {
- const keyValueListeners = {
- keydown: (event) => {
- switch (event.key) {
- case 'Enter':
- case 'Escape':
- return;
- }
-
- event.preventDefault();
-
- event.target.value = event.key;
-
- event.target.dispatchEvent(new InputEvent('input'));
- },
- };
-
- const isCSSRule = (() => {
- const wrapper = document.createElement('style');
- const regex = /\s/g;
-
- return (property, text) => {
- const ruleText = `${property}:${text};`;
-
- document.head.appendChild(wrapper);
- wrapper.sheet.insertRule(`:not(*){${ruleText}}`);
-
- const [{style: {cssText}}] = wrapper.sheet.cssRules;
-
- wrapper.remove();
-
- return cssText.replaceAll(regex, '') === ruleText.replaceAll(regex, '') || `Must be a valid CSS ${property} rule`;
- };
- })();
-
- const getHideId = (() => {
- let id = 0;
-
- return () => `${id++}`;
- })();
-
- const getHideable = (() => {
- const node = {
- label: 'Enable?',
- get: ({value: on}) => ({on}),
- };
-
- return (children, value = true, hideId = getHideId()) => ([
- {...node, value, onUpdate: (value) => ({hide: {[hideId]: !value}})},
- ...children.map((child) => ({...child, hideId})),
- ]);
- })();
-
- const bgHideId = getHideId();
-
- return {
- get: (_, configs) => Object.assign(...configs),
- children: [
- {
- label: 'Controls',
- children: [
- {
- label: 'Key Combinations',
- descendantPredicate: (children) => {
- const isMatch = ({children: a}, {children: b}) => {
- if (a.length !== b.length) {
- return false;
- }
-
- return a.every(({value: keyA}) => b.some(({value: keyB}) => keyA === keyB));
- };
-
- for (let i = 1; i < children.length; ++i) {
- if (children.slice(i).some((child) => isMatch(children[i - 1], child))) {
- return 'Another action has this key combination';
- }
- }
-
- return true;
- },
- get: (_, configs) => ({keys: Object.assign(...configs)}),
- children: ((shift = navigator.userAgent.includes('Firefox') ? '\\' : 'Shift') => ([
- ['On / Off', ['x'], 'on'],
- ['Config', ['Alt', 'x'], 'config'],
- ['Pan / Zoom', ['Control'], 'pan'],
- ['Rotate', [shift], 'rotate'],
- ['Crop', ['Control', shift], 'crop'],
- ]))().map(([label, values, key]) => ({
- label,
- seed: {
- value: '',
- listeners: keyValueListeners,
- },
- children: values.map((value) => ({value, listeners: keyValueListeners})),
- get: ({children}) => ({[key]: new Set(children.map(({value}) => value.toLowerCase()))}),
- })),
- },
- {
- label: 'Scroll Speeds',
- get: (_, configs) => ({speeds: Object.assign(...configs)}),
- children: [
- {
- label: 'Zoom',
- value: -100,
- get: ({value}) => ({zoom: value / 150000}),
- },
- {
- label: 'Rotate',
- value: 100,
- get: ({value}) => ({rotate: value / 100000}),
- },
- {
- label: 'Crop',
- value: -100,
- get: ({value}) => ({crop: value / 300000}),
- },
- ],
- },
- {
- label: 'Drag Inversions',
- get: (_, configs) => ({multipliers: Object.assign(...configs)}),
- children: [
- ['Pan', 'pan'],
- ['Rotate', 'rotate'],
- ['Crop', 'crop'],
- ].map(([label, key, value = true]) => ({
- label,
- value,
- get: ({value}) => ({[key]: value ? -1 : 1}),
- })),
- },
- {
- label: 'Click Movement Allowance (px)',
- value: 2,
- predicate: (value) => value >= 0 || 'Allowance must be positive',
- inputAttributes: {min: 0},
- get: ({value: clickCutoff}) => ({clickCutoff}),
- },
- ],
- },
- {
- label: 'Behaviour',
- children: [
- ...[
- ['Zoom In Limit', 'zoomInLimit', 500],
- ['Zoom Out Limit', 'zoomOutLimit', 80],
- ['Pan Limit', 'panLimit', 50],
- ].map(([label, key, customValue, value = 'Custom', options = ['None', 'Custom', 'Frame'], hideId = getHideId()]) => ({
- label,
- get: (_, configs) => ({[key]: Object.assign(...configs)}),
- children: [
- {
- label: 'Type',
- value,
- options,
- get: ({value}) => ({type: options.indexOf(value)}),
- onUpdate: (value) => ({hide: {[hideId]: value !== options[1]}}),
- },
- {
- label: 'Limit (%)',
- value: customValue,
- predicate: (value) => value >= 0 || 'Limit must be positive',
- inputAttributes: {min: 0},
- get: ({value}) => ({value: value / 100}),
- hideId,
- },
- ],
- })),
- {
- label: 'Peek On Button Hover?',
- value: false,
- get: ({value: peek}) => ({peek}),
- },
- {
- label: 'Active Effects',
- get: (_, configs) => ({active: Object.assign(...configs)}),
- children: [
- {
- label: 'Pause Video?',
- value: false,
- get: ({value: pause}) => ({pause}),
- },
- {
- label: 'Overlay Deactivation',
- get: (_, configs) => {
- const {on, hide} = Object.assign(...configs);
-
- return {overlayRule: on ? ([hide ? 'display' : 'pointer-events', 'none']) : false};
- },
- children: getHideable([
- {
- label: 'Hide?',
- value: false,
- get: ({value: hide}) => ({hide}),
- },
- ]),
- },
- {
- label: 'Hide Background?',
- value: false,
- get: ({value: hideBg}) => ({hideBg}),
- hideId: bgHideId,
- },
- ],
- },
-
- ],
- },
- {
- label: 'Background',
- get: (_, configs) => {
- const {turnover, ...config} = Object.assign(...configs);
- const sampleCount = Math.floor(config.fps * turnover);
-
- // avoid taking more samples than there's space for
- if (sampleCount > config.size) {
- const fps = config.size / turnover;
-
- return {
- background: {
- ...config,
- sampleCount: config.size,
- interval: 1000 / fps,
- fps,
- },
- };
- }
-
- return {
- background: {
- ...config,
- interval: 1000 / config.fps,
- sampleCount,
- },
- };
- },
- children: getHideable([
- {
- label: 'Replace?',
- value: true,
- get: ({value: replace}) => ({replace}),
- },
- {
- label: 'Filter',
- value: 'saturate(1.5) brightness(1.5) blur(25px)',
- predicate: isCSSRule.bind(null, 'filter'),
- get: ({value: filter}) => ({filter}),
- },
- {
- label: 'Update',
- childPredicate: ([{value: fps}, {value: turnover}]) => (fps * turnover) >= 1 || `${turnover} second turnover cannot be achieved at ${fps} hertz`,
- children: [
- {
- label: 'Frequency (Hz)',
- value: 15,
- predicate: (value) => {
- if (value > 144) {
- return 'Update frequency may not be above 144 hertz';
- }
-
- return value >= 0 || 'Update frequency must be positive';
- },
- inputAttributes: {min: 0, max: 144},
- get: ({value: fps}) => ({fps}),
- },
- {
- label: 'Turnover Time (s)',
- value: 3,
- predicate: (value) => value >= 0 || 'Turnover time must be positive',
- inputAttributes: {min: 0},
- get: ({value: turnover}) => ({turnover}),
- },
- {
- label: 'Reverse?',
- value: false,
- get: ({value: flip}) => ({flip}),
- },
- ],
- },
- {
- label: 'Size (px)',
- value: 50,
- predicate: (value) => value >= 0 || 'Size must be positive',
- inputAttributes: {min: 0},
- get: ({value}) => ({size: value}),
- },
- {
- label: 'End Point (%)',
- value: 103,
- predicate: (value) => value >= 0 || 'End point must be positive',
- inputAttributes: {min: 0},
- get: ({value}) => ({end: value / 100}),
- },
- ], true, bgHideId),
- },
- {
- label: 'Interfaces',
- children: [
- {
- label: 'Crop',
- get: (_, configs) => ({crop: Object.assign(...configs)}),
- children: [
- {
- label: 'Colours',
- get: (_, configs) => ({colour: Object.assign(...configs)}),
- children: [
- {
- label: 'Fill',
- get: (_, [colour, opacity]) => ({fill: `${colour}${opacity}`}),
- children: [
- {
- label: 'Colour',
- value: '#808080',
- input: 'color',
- get: ({value}) => value,
- },
- {
- label: 'Opacity (%)',
- value: 40,
- predicate: (value) => {
- if (value < 0) {
- return 'Opacity must be positive';
- }
-
- return value <= 100 || 'Opacity may not exceed 100%';
- },
- inputAttributes: {min: 0, max: 100},
- get: ({value}) => Math.round(255 * value / 100).toString(16),
- },
- ],
- },
- {
- label: 'Shadow',
- value: '#000000',
- input: 'color',
- get: ({value: shadow}) => ({shadow}),
- },
- {
- label: 'Border',
- value: '#ffffff',
- input: 'color',
- get: ({value: border}) => ({border}),
- },
- ],
- },
- {
- label: 'Handle Size (%)',
- value: 6,
- predicate: (value) => {
- if (value < 0) {
- return 'Size must be positive';
- }
-
- return value <= 50 || 'Size may not exceed 50%';
- },
- inputAttributes: {min: 0, max: 50},
- get: ({value: handle}) => ({handle}),
- },
- ],
- },
- {
- label: 'Crosshair',
- get: (_, configs) => ({crosshair: Object.assign(...configs)}),
- children: getHideable([
- {
- label: 'Outer Thickness (px)',
- value: 3,
- predicate: (value) => value >= 0 || 'Thickness must be positive',
- inputAttributes: {min: 0},
- get: ({value: outer}) => ({outer}),
- },
- {
- label: 'Inner Thickness (px)',
- value: 1,
- predicate: (value) => value >= 0 || 'Thickness must be positive',
- inputAttributes: {min: 0},
- get: ({value: inner}) => ({inner}),
- },
- {
- label: 'Inner Diameter (px)',
- value: 157,
- predicate: (value) => value >= 0 || 'Diameter must be positive',
- inputAttributes: {min: 0},
- get: ({value: gap}) => ({gap}),
- },
- {
- label: 'Text',
- get: (_, configs) => {
- const {translateX, translateY, ...config} = Object.assign(...configs);
-
- return {
- text: {
- translate: {
- x: translateX,
- y: translateY,
- },
- ...config,
- },
- };
- },
- children: getHideable([
- {
- label: 'Font',
- value: '30px "Harlow Solid", cursive',
- predicate: isCSSRule.bind(null, 'font'),
- get: ({value: font}) => ({font}),
- },
- {
- label: 'Position (%)',
- get: (_, configs) => ({position: Object.assign(...configs)}),
- children: ['x', 'y'].map((label) => ({
- label,
- value: 0,
- predicate: (value) => Math.abs(value) <= 50 || 'Position must be on-screen',
- inputAttributes: {min: -50, max: 50},
- get: ({value}) => ({[label]: value + 50}),
- })),
- },
- {
- label: 'Offset (px)',
- get: (_, configs) => ({offset: Object.assign(...configs)}),
- children: [
- {
- label: 'x',
- value: -6,
- get: ({value: x}) => ({x}),
- },
- {
- label: 'y',
- value: -25,
- get: ({value: y}) => ({y}),
- },
- ],
- },
- (() => {
- const options = ['Left', 'Center', 'Right'];
-
- return {
- label: 'Alignment',
- value: options[2],
- options,
- get: ({value}) => ({align: value.toLowerCase(), translateX: options.indexOf(value) * -50}),
- };
- })(),
- (() => {
- const options = ['Top', 'Middle', 'Bottom'];
-
- return {
- label: 'Baseline',
- value: options[0],
- options,
- get: ({value}) => ({translateY: options.indexOf(value) * -50}),
- };
- })(),
- {
- label: 'Line height (%)',
- value: 90,
- predicate: (value) => value >= 0 || 'Height must be positive',
- inputAttributes: {min: 0},
- get: ({value}) => ({height: value / 100}),
- },
- ]),
- },
- {
- label: 'Colours',
- get: (_, configs) => ({colour: Object.assign(...configs)}),
- children: [
- {
- label: 'Fill',
- value: '#ffffff',
- input: 'color',
- get: ({value: fill}) => ({fill}),
- },
- {
- label: 'Shadow',
- value: '#000000',
- input: 'color',
- get: ({value: shadow}) => ({shadow}),
- },
- ],
- },
- ]),
- },
- ],
- },
- ],
- };
- })(),
- {
- headBase: '#c80000',
- headButtonExit: '#000000',
- borderHead: '#ffffff',
- borderTooltip: '#c80000',
- width: Math.min(90, screen.width / 16),
- height: 90,
- },
- {
- zIndex: 10000,
- scrollbarColor: 'initial',
- },
- );
-
- const PI_HALVES = [Math.PI / 2, Math.PI, 3 * Math.PI / 2, Math.PI * 2];
-
- const SELECTOR_ROOT = '#ytd-player > *';
- const SELECTOR_VIEWPORT = '#movie_player';
- const SELECTOR_VIDEO = 'video.video-stream.html5-main-video';
-
- let isOn = false;
-
- let video;
- let altTarget;
- let viewport;
-
- const viewportSectorAngles = {};
-
- let videoAngle = PI_HALVES[0];
- let zoom = 1;
- const midPoint = {x: 0, y: 0};
- const crop = {top: 0, right: 0, bottom: 0, left: 0};
-
- const css = new function() {
- this.has = (name) => document.body.classList.contains(name);
- this.tag = (name, doAdd = true) => document.body.classList[doAdd ? 'add' : 'remove'](name);
-
- this.getSelector = (...classes) => `body.${classes.join('.')}`;
-
- const getSheet = () => {
- const element = document.createElement('style');
-
- document.head.appendChild(element);
-
- return element.sheet;
- };
-
- const getRuleString = (selector, ...declarations) => `${selector}{${declarations.map(([property, value]) => `${property}:${value};`).join('')}}`;
-
- this.add = function(...rule) {
- this.insertRule(getRuleString(...rule));
- }.bind(getSheet());
-
- this.Toggleable = class {
- static sheet = getSheet();
-
- static active = [];
-
- static id = 0;
-
- static add(rule, id) {
- this.sheet.insertRule(rule, this.active.length);
-
- this.active.push(id);
- }
-
- static remove(id) {
- let index = this.active.indexOf(id);
-
- while (index >= 0) {
- this.sheet.deleteRule(index);
-
- this.active.splice(index, 1);
-
- index = this.active.indexOf(id);
- }
- }
-
- id = this.constructor.id++;
-
- add(...rule) {
- this.constructor.add(getRuleString(...rule), this.id);
- }
-
- remove() {
- this.constructor.remove(this.id);
- }
- };
- }();
-
- // Reads user input to start & stop actions
- const Enabler = new function() {
- this.CLASS_ABLE = 'YTVC-action-able';
- this.CLASS_DRAGGING = 'ytvc-action-dragging';
-
- this.keys = new Set();
-
- this.didPause = false;
- this.isHidingBg = false;
-
- this.getAvailable = () => {
- const {keys} = $config.get();
-
- for (const action of Object.values(actions)) {
- if (this.keys.isSupersetOf(keys[action.CODE])) {
- return action;
- }
- }
-
- return null;
- };
-
- this.setActive = (action) => {
- const {active} = $config.get();
-
- if (active.hideBg && Boolean(action) !== this.isHidingBg) {
- if (action) {
- this.isHidingBg = true;
-
- background.hide();
- } else if (this.isHidingBg) {
- this.isHidingBg = false;
-
- background.show();
- }
- }
-
- this.activeAction?.onInactive();
-
- if (action) {
- this.activeAction = action;
-
- action.onActive?.();
-
- if (active.pause && !video.paused) {
- video.pause();
-
- this.didPause = true;
- }
-
- return;
- }
-
- if (this.didPause) {
- video.play();
-
- this.didPause = false;
- }
-
- this.activeAction = undefined;
- };
-
- this.handleChange = () => {
- if (!video || stopDrag || video.ended) {
- return;
- }
-
- const {keys} = $config.get();
-
- let activeAction = null;
-
- for (const action of Object.values(actions)) {
- if (this.keys.isSupersetOf(keys[action.CODE])) {
- if (!activeAction) {
- activeAction = action;
-
- continue;
- }
-
- if (keys[activeAction.CODE].size < keys[action.CODE].size) {
- css.tag(activeAction.CLASS_ABLE, false);
-
- activeAction = action;
-
- continue;
- }
- }
-
- css.tag(action.CLASS_ABLE, false);
- }
-
- if (activeAction === this.activeAction) {
- return;
- }
-
- css.tag(this.CLASS_ABLE, activeAction);
-
- if (activeAction) {
- css.tag(activeAction.CLASS_ABLE);
- }
-
- this.setActive(activeAction);
- };
-
- this.updateConfig = (() => {
- const rule = new css.Toggleable();
- const selector = `${css.getSelector(this.CLASS_ABLE)} :where(.ytp-chrome-bottom,.ytp-chrome-top,.ytp-gradient-bottom,.ytp-gradient-top),` +
- // I guess ::after doesn't work with :where
- `${css.getSelector(this.CLASS_ABLE)} #contentContainer.tp-yt-app-drawer[swipe-open]::after`;
-
- return () => {
- const {overlayRule} = $config.get().active;
-
- rule.remove();
-
- if (overlayRule) {
- rule.add(selector, overlayRule);
- }
- };
- })();
-
- $config.ready.then(() => {
- this.updateConfig();
- });
-
- // insertion order decides priority
- css.add(`${css.getSelector(this.CLASS_DRAGGING)} ${SELECTOR_VIEWPORT}`, ['cursor', 'grabbing']);
- css.add(`${css.getSelector(this.CLASS_ABLE)} ${SELECTOR_VIEWPORT}`, ['cursor', 'grab']);
- }();
-
- const containers = (() => {
- const containers = Object.fromEntries(['background', 'foreground', 'tracker'].map((key) => [key, document.createElement('div')]));
-
- containers.background.style.position = containers.foreground.style.position = 'absolute';
- containers.background.style.pointerEvents = containers.foreground.style.pointerEvents = containers.tracker.style.pointerEvents = 'none';
- containers.tracker.style.height = containers.tracker.style.width = '100%';
-
- // make an outline of the uncropped video
- const backgroundId = 'ytvc-container-background';
- containers.background.id = backgroundId;
- containers.background.style.boxSizing = 'border-box';
- css.add(`${css.getSelector(Enabler.CLASS_ABLE)} #${backgroundId}`, ['border', '1px solid white']);
-
- return containers;
- })();
-
- const setViewportAngles = () => {
- viewportSectorAngles.side = getTheta(0, 0, viewport.clientWidth, viewport.clientHeight);
-
- // equals `getTheta(0, 0, viewport.clientHeight, viewport.clientWidth)`
- viewportSectorAngles.base = PI_HALVES[0] - viewportSectorAngles.side;
-
- background.handleViewChange(true);
- };
-
- const getCroppedWidth = (width = video.clientWidth) => width * (1 - crop.left - crop.right);
- const getCroppedHeight = (height = video.clientHeight) => height * (1 - crop.top - crop.bottom);
-
- let stopDrag;
-
- const handleMouseDown = (event, clickCallback, dragCallback, target = video) => new Promise((resolve) => {
- event.stopImmediatePropagation();
- event.preventDefault();
-
- target.setPointerCapture(event.pointerId);
-
- css.tag(Enabler.CLASS_DRAGGING);
-
- const clickDisallowListener = ({clientX, clientY}) => {
- const {clickCutoff} = $config.get();
- const distance = Math.abs(event.clientX - clientX) + Math.abs(event.clientY - clientY);
-
- if (distance >= clickCutoff) {
- target.removeEventListener('pointermove', clickDisallowListener);
- target.removeEventListener('pointerup', clickCallback);
- }
- };
-
- const doubleClickListener = (event) => {
- event.stopImmediatePropagation();
- };
-
- target.addEventListener('pointermove', clickDisallowListener);
- target.addEventListener('pointermove', dragCallback);
- target.addEventListener('pointerup', clickCallback, {once: true});
- viewport.parentElement.addEventListener('dblclick', doubleClickListener, true);
-
- stopDrag = () => {
- css.tag(Enabler.CLASS_DRAGGING, false);
-
- target.removeEventListener('pointermove', clickDisallowListener);
- target.removeEventListener('pointermove', dragCallback);
- target.removeEventListener('pointerup', clickCallback);
-
- // wait for a possible dblclick event to be dispatched
- window.setTimeout(() => {
- viewport.parentElement.removeEventListener('dblclick', doubleClickListener, true);
- viewport.parentElement.removeEventListener('click', clickListener, true);
- }, 0);
-
- window.removeEventListener('blur', stopDrag);
- target.removeEventListener('pointerup', stopDrag);
-
- target.releasePointerCapture(event.pointerId);
-
- stopDrag = undefined;
-
- Enabler.handleChange();
-
- resolve();
- };
-
- window.addEventListener('blur', stopDrag);
- target.addEventListener('pointerup', stopDrag);
-
- const clickListener = (event) => {
- event.stopImmediatePropagation();
- event.preventDefault();
- };
-
- viewport.parentElement.addEventListener('click', clickListener, true);
- });
-
- const background = (() => {
- const videoCanvas = new OffscreenCanvas(0, 0);
- const videoCtx = videoCanvas.getContext('2d', {alpha: false});
-
- const bgCanvas = document.createElement('canvas');
- const bgCtx = bgCanvas.getContext('2d', {alpha: false});
-
- bgCanvas.style.setProperty('position', 'absolute');
-
- class Sector {
- canvas = new OffscreenCanvas(0, 0);
- ctx = this.canvas.getContext('2d', {alpha: false});
-
- update(doFill) {
- if (doFill) {
- this.fill();
- } else {
- this.shift();
- this.take();
- }
-
- this.giveEdge();
-
- if (this.hasCorners) {
- this.giveCorners();
- }
- }
- }
-
- class Side extends Sector {
- setDimensions(doShiftRight, sWidth, sHeight, sx, sy, dx, dy, dWidth, dHeight) {
- this.canvas.width = sWidth;
- this.canvas.height = sHeight;
-
- this.shift = this.ctx.drawImage.bind(this.ctx, this.canvas, doShiftRight ? 1 : -1, 0);
-
- this.fill = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, 1, sHeight, 0, 0, sWidth, sHeight);
- this.take = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, 1, sHeight, doShiftRight ? 0 : sWidth - 1, 0, 1, sHeight);
-
- this.giveEdge = bgCtx.drawImage.bind(bgCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);
-
- if (dy === 0) {
- this.hasCorners = false;
-
- return;
- }
-
- this.hasCorners = true;
-
- const giveCorner0 = bgCtx.drawImage.bind(bgCtx, this.canvas, 0, 0, sWidth, 1, dx, 0, dWidth, dy);
- const giveCorner1 = bgCtx.drawImage.bind(bgCtx, this.canvas, 0, sHeight - 1, sWidth, 1, dx, dy + dHeight, dWidth, dy);
-
- this.giveCorners = () => {
- giveCorner0();
- giveCorner1();
- };
- }
- }
-
- class Base extends Sector {
- setDimensions(doShiftDown, sWidth, sHeight, sx, sy, dx, dy, dWidth, dHeight) {
- this.canvas.width = sWidth;
- this.canvas.height = sHeight;
-
- this.shift = this.ctx.drawImage.bind(this.ctx, this.canvas, 0, doShiftDown ? 1 : -1);
-
- this.fill = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, sWidth, 1, 0, 0, sWidth, sHeight);
- this.take = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, sWidth, 1, 0, doShiftDown ? 0 : sHeight - 1, sWidth, 1);
-
- this.giveEdge = bgCtx.drawImage.bind(bgCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);
-
- if (dx === 0) {
- this.hasCorners = false;
-
- return;
- }
-
- this.hasCorners = true;
-
- const giveCorner0 = bgCtx.drawImage.bind(bgCtx, this.canvas, 0, 0, 1, sHeight, 0, dy, dx, dHeight);
- const giveCorner1 = bgCtx.drawImage.bind(bgCtx, this.canvas, sWidth - 1, 0, 1, sHeight, dx + dWidth, dy, dx, dHeight);
-
- this.giveCorners = () => {
- giveCorner0();
- giveCorner1();
- };
- }
-
- setClipPath(points) {
- this.clipPath = new Path2D();
-
- this.clipPath.moveTo(...points[0]);
- this.clipPath.lineTo(...points[1]);
- this.clipPath.lineTo(...points[2]);
- this.clipPath.closePath();
- }
-
- update(doFill) {
- bgCtx.save();
-
- bgCtx.clip(this.clipPath);
-
- super.update(doFill);
-
- bgCtx.restore();
- }
- }
-
- const components = {
- left: new Side(),
- right: new Side(),
- top: new Base(),
- bottom: new Base(),
- };
-
- const setComponentDimensions = (sampleCount, size, isInset, doFlip) => {
- const croppedWidth = getCroppedWidth();
- const croppedHeight = getCroppedHeight();
- const halfCanvas = {x: Math.ceil(bgCanvas.width / 2), y: Math.ceil(bgCanvas.height / 2)};
- const halfVideo = {x: croppedWidth / 2, y: croppedHeight / 2};
- const dWidth = Math.ceil(Math.min(halfVideo.x, size));
- const dHeight = Math.ceil(Math.min(halfVideo.y, size));
- const [dWidthScale, dHeightScale, sideWidth, sideHeight] = isInset ?
- [0, 0, videoCanvas.width / croppedWidth * bgCanvas.width, videoCanvas.height / croppedHeight * bgCanvas.height] :
- [halfCanvas.x - halfVideo.x, halfCanvas.y - halfVideo.y, croppedWidth, croppedHeight];
-
- components.left.setDimensions(!doFlip, sampleCount, videoCanvas.height, 0, 0, 0, dHeightScale, dWidth, sideHeight);
-
- components.right.setDimensions(doFlip, sampleCount, videoCanvas.height, videoCanvas.width - 1, 0, bgCanvas.width - dWidth, dHeightScale, dWidth, sideHeight);
-
- components.top.setDimensions(!doFlip, videoCanvas.width, sampleCount, 0, 0, dWidthScale, 0, sideWidth, dHeight);
- components.top.setClipPath([[0, 0], [halfCanvas.x, halfCanvas.y], [bgCanvas.width, 0]]);
-
- components.bottom.setDimensions(doFlip, videoCanvas.width, sampleCount, 0, videoCanvas.height - 1, dWidthScale, bgCanvas.height - dHeight, sideWidth, dHeight);
- components.bottom.setClipPath([[0, bgCanvas.height], [halfCanvas.x, halfCanvas.y], [bgCanvas.width, bgCanvas.height]]);
- };
-
- class Instance {
- constructor() {
- const {filter, sampleCount, size, end, doFlip} = $config.get().background;
-
- const endX = end * getCroppedWidth();
- const endY = end * getCroppedHeight();
-
- // Setup canvases
-
- bgCanvas.style.setProperty('filter', filter);
-
- bgCanvas.width = endX;
- bgCanvas.height = endY;
-
- bgCanvas.style.setProperty('left', `${crop.left * 100 + (1 - end) * (1 - crop.left - crop.right) * 50}%`);
- bgCanvas.style.setProperty('top', `${crop.top * 100 + (1 - end) * (1 - crop.top - crop.bottom) * 50}%`);
-
- videoCanvas.width = getCroppedWidth(video.videoWidth);
- videoCanvas.height = getCroppedHeight(video.videoHeight);
-
- setComponentDimensions(sampleCount, size, end <= 1, doFlip);
-
- this.update(true);
- }
-
- update(doFill = false) {
- videoCtx.drawImage(
- video,
- crop.left * video.videoWidth,
- crop.top * video.videoHeight,
- video.videoWidth * (1 - crop.left - crop.right),
- video.videoHeight * (1 - crop.top - crop.bottom),
- 0,
- 0,
- videoCanvas.width,
- videoCanvas.height,
- );
-
- components.left.update(doFill);
- components.right.update(doFill);
- components.top.update(doFill);
- components.bottom.update(doFill);
- }
- }
-
- return new function() {
- const container = document.createElement('div');
-
- container.style.display = 'none';
-
- container.appendChild(bgCanvas);
- containers.background.appendChild(container);
-
- const CLASS_CINOMATICS = 'ytvc-background-hide-cinematics';
-
- css.add(`${css.getSelector(CLASS_CINOMATICS)} #cinematics`, ['display', 'none']);
-
- this.isHidden = false;
-
- let instance, startCopyLoop, stopCopyLoop;
-
- const play = () => {
- if (!video.paused && !this.isHidden && !Enabler.isHidingBg) {
- startCopyLoop?.();
- }
- };
-
- const fill = () => {
- if (!this.isHidden) {
- instance.update(true);
- }
- };
-
- const handleVisibilityChange = () => {
- if (document.hidden) {
- stopCopyLoop();
- } else {
- play();
- }
- };
-
- this.handleSizeChange = () => {
- instance = new Instance();
- };
-
- // set up pausing if background isn't visible
- this.handleViewChange = (() => {
- let priorAngle, priorZoom, cornerTop, cornerBottom;
-
- return (doForce = false) => {
- if (doForce || videoAngle !== priorAngle || zoom !== priorZoom) {
- const viewportX = viewport.clientWidth / 2 / zoom;
- const viewportY = viewport.clientHeight / 2 / zoom;
-
- const angle = PI_HALVES[0] - videoAngle;
-
- cornerTop = getGenericRotated(viewportX, viewportY, angle);
- cornerBottom = getGenericRotated(viewportX, -viewportY, angle);
-
- cornerTop.x = Math.abs(cornerTop.x);
- cornerTop.y = Math.abs(cornerTop.y);
- cornerBottom.x = Math.abs(cornerBottom.x);
- cornerBottom.y = Math.abs(cornerBottom.y);
-
- priorAngle = videoAngle;
- priorZoom = zoom;
- }
-
- const videoX = Math.abs(midPoint.x) * video.clientWidth;
- const videoY = Math.abs(midPoint.y) * video.clientHeight;
-
- for (const corner of [cornerTop, cornerBottom]) {
- if (
- videoX + corner.x > (0.5 - crop.right) * video.clientWidth + 1 ||
- videoX - corner.x < (crop.left - 0.5) * video.clientWidth - 1 ||
- videoY + corner.y > (0.5 - crop.top) * video.clientHeight + 1 ||
- videoY - corner.y < (crop.bottom - 0.5) * video.clientHeight - 1
- ) {
- // fill if newly visible
- if (this.isHidden) {
- instance?.update(true);
- }
-
- this.isHidden = false;
-
- bgCanvas.style.removeProperty('visibility');
-
- play();
-
- return;
- }
- }
-
- this.isHidden = true;
-
- bgCanvas.style.visibility = 'hidden';
-
- stopCopyLoop?.();
- };
- })();
-
- const loop = {};
-
- this.start = () => {
- const config = $config.get().background;
-
- if (!config.on) {
- return;
- }
-
- if (!Enabler.isHidingBg) {
- container.style.removeProperty('display');
- }
-
- if (config.replace) {
- css.tag(CLASS_CINOMATICS);
- }
-
- // todo handle this?
- if (getCroppedWidth() === 0 || getCroppedHeight() === 0 || video.videoWidth === 0 || video.videoHeight === 0) {
- return;
- }
-
- let loopId = -1;
-
- if (loop.interval !== config.interval || loop.fps !== config.fps) {
- loop.interval = config.interval;
- loop.fps = config.fps;
- loop.wasSlow = false;
- loop.throttleCount = 0;
- }
-
- stopCopyLoop = () => ++loopId;
-
- instance = new Instance();
-
- startCopyLoop = async () => {
- const id = ++loopId;
-
- await new Promise((resolve) => {
- window.setTimeout(resolve, config.interval);
- });
-
- while (id === loopId) {
- const startTime = Date.now();
-
- instance.update();
-
- const delay = loop.interval - (Date.now() - startTime);
-
- if (delay <= 0) {
- if (loop.wasSlow) {
- loop.interval = 1000 / (loop.fps - ++loop.throttleCount);
- }
-
- loop.wasSlow = !loop.wasSlow;
-
- continue;
- }
-
- if (delay > 2 && loop.throttleCount > 0) {
- console.warn(`[${GM.info.script.name}] Background update frequency reduced from ${loop.fps} hertz to ${loop.fps - loop.throttleCount} hertz due to poor performance.`);
-
- loop.fps -= loop.throttleCount;
-
- loop.throttleCount = 0;
- }
-
- loop.wasSlow = false;
-
- await new Promise((resolve) => {
- window.setTimeout(resolve, delay);
- });
- }
- };
-
- play();
-
- video.addEventListener('pause', stopCopyLoop);
- video.addEventListener('play', play);
- video.addEventListener('seeked', fill);
-
- document.addEventListener('visibilitychange', handleVisibilityChange);
- };
-
- const priorCrop = {};
-
- this.hide = () => {
- Object.assign(priorCrop, crop);
-
- stopCopyLoop?.();
-
- container.style.display = 'none';
- };
-
- this.show = () => {
- if (Object.entries(priorCrop).some(([edge, value]) => crop[edge] !== value)) {
- this.reset();
- } else {
- play();
- }
-
- container.style.removeProperty('display');
- };
-
- this.stop = () => {
- this.hide();
-
- css.tag(CLASS_CINOMATICS, false);
-
- video.removeEventListener('pause', stopCopyLoop);
- video.removeEventListener('play', play);
- video.removeEventListener('seeked', fill);
-
- document.removeEventListener('visibilitychange', handleVisibilityChange);
-
- startCopyLoop = undefined;
- stopCopyLoop = undefined;
- };
-
- this.reset = () => {
- this.stop();
-
- this.start();
- };
- }();
- })();
-
- const resetMidPoint = () => {
- midPoint.x = 0;
- midPoint.y = 0;
-
- applyMidPoint();
- };
-
- const resetZoom = () => {
- zoom = 1;
-
- applyZoom();
- };
-
- const resetRotation = () => {
- videoAngle = PI_HALVES[0];
-
- applyRotation();
-
- ensureFramed();
- };
-
- const getFitContentZoom = (width = 1, height = 1) => {
- const corner0 = getRotated(width, height, false);
- const corner1 = getRotated(-width, height, false);
-
- return 1 / Math.max(
- Math.abs(corner0.x) / viewport.clientWidth, Math.abs(corner1.x) / viewport.clientWidth,
- Math.abs(corner0.y) / viewport.clientHeight, Math.abs(corner1.y) / viewport.clientHeight,
- );
- };
-
- const addListeners = ({onMouseDown, onRightClick, onScroll}, doAdd = true) => {
- const property = `${doAdd ? 'add' : 'remove'}EventListener`;
-
- altTarget[property]('pointerdown', onMouseDown);
- altTarget[property]('contextmenu', onRightClick, true);
- altTarget[property]('wheel', onScroll);
- };
-
- const getTheta = (fromX, fromY, toX, toY) => Math.atan2(toY - fromY, toX - fromX);
-
- const getGenericRotated = (x, y, angle) => {
- const radius = Math.sqrt((x * x) + (y * y));
- const pointTheta = getTheta(0, 0, x, y) + angle;
-
- return {
- x: radius * Math.cos(pointTheta),
- y: radius * Math.sin(pointTheta),
- };
- };
-
- const getRotated = (xRaw, yRaw, ratio = true) => {
- // Multiplying by video dimensions to have the axes' scales match the video's
- // Using midPoint's raw values would only work if points moved elliptically around the centre of rotation
- const rotated = getGenericRotated(xRaw * video.clientWidth, yRaw * video.clientHeight, videoAngle - PI_HALVES[0]);
-
- return ratio ? {x: rotated.x / video.clientWidth, y: rotated.y / video.clientHeight} : rotated;
- };
-
- const applyZoom = (() => {
- const getFramer = (() => {
- let priorTheta, fitContentZoom;
-
- return () => {
- if (videoAngle !== priorTheta) {
- priorTheta = videoAngle;
- fitContentZoom = getFitContentZoom();
- }
-
- return fitContentZoom;
- };
- })();
-
- const constrain = () => {
- const {zoomOutLimit, zoomInLimit} = $config.get();
- const framer = getFramer();
-
- if (zoomOutLimit.type > 0) {
- zoom = Math.max(zoomOutLimit.type === 1 ? zoomOutLimit.value : framer, zoom);
- }
-
- if (zoomInLimit.type > 0) {
- zoom = Math.min(zoomInLimit.type === 1 ? zoomInLimit.value : framer, zoom);
- }
- };
-
- return (doApply = true) => {
- if (isOn) {
- constrain();
- }
-
- if (doApply) {
- video.style.setProperty('scale', `${zoom}`);
- }
-
- return zoom;
- };
- })();
-
- const applyMidPoint = () => {
- const {x, y} = getRotated(midPoint.x, midPoint.y);
-
- video.style.setProperty('translate', `${-x * zoom * 100}% ${y * zoom * 100}%`);
- };
-
- const ensureFramed = (() => {
- const applyFrameValues = (lowCorner, highCorner, sub, main) => {
- midPoint[sub] = Math.max(-lowCorner[sub], Math.min(highCorner[sub], midPoint[sub]));
-
- const progress = (midPoint[sub] + lowCorner[sub]) / (highCorner[sub] + lowCorner[sub]);
-
- if (midPoint[main] < 0) {
- const bound = Number.isNaN(progress) ?
- -lowCorner[main] :
- ((lowCorner[main] - highCorner[main]) * progress - lowCorner[main]);
-
- midPoint[main] = Math.max(midPoint[main], bound);
- } else {
- const bound = Number.isNaN(progress) ?
- lowCorner[main] :
- ((highCorner[main] - lowCorner[main]) * progress + lowCorner[main]);
-
- midPoint[main] = Math.min(midPoint[main], bound);
- }
- };
-
- const applyFrame = (firstCorner, secondCorner, firstCornerAngle, secondCornerAngle) => {
- // The anti-clockwise angle from the first (top left) corner
- const midPointAngle = (getTheta(0, 0, midPoint.x, midPoint.y) + PI_HALVES[1] + firstCornerAngle) % PI_HALVES[3];
-
- if ((midPointAngle % PI_HALVES[1]) < secondCornerAngle) {
- // Frame is x-bound
- const [lowCorner, highCorner] = midPoint.x >= 0 ? [firstCorner, secondCorner] : [secondCorner, firstCorner];
-
- applyFrameValues(lowCorner, highCorner, 'y', 'x');
- } else {
- // Frame is y-bound
- const [lowCorner, highCorner] = midPoint.y >= 0 ? [firstCorner, secondCorner] : [secondCorner, firstCorner];
-
- applyFrameValues(lowCorner, highCorner, 'x', 'y');
- }
- };
-
- const getBoundApplyFrame = (() => {
- const getCorner = (first, second) => {
- if (zoom < first.z) {
- return {x: 0, y: 0};
- }
-
- if (zoom < second.z) {
- const progress = (1 / zoom - 1 / first.z) / (1 / second.z - 1 / first.z);
-
- return {
- x: progress * (second.x - first.x) + first.x,
- y: progress * (second.y - first.y) + first.y,
- };
- }
-
- return {
- x: Math.max(0, 0.5 - ((0.5 - second.x) / (zoom / second.z))),
- y: Math.max(0, 0.5 - ((0.5 - second.y) / (zoom / second.z))),
- };
- };
-
- return (first0, second0, first1, second1) => {
- const fFirstCorner = getCorner(first0, second0);
- const fSecondCorner = getCorner(first1, second1);
-
- const fFirstCornerAngle = getTheta(0, 0, fFirstCorner.x, fFirstCorner.y);
- const fSecondCornerAngle = fFirstCornerAngle + getTheta(0, 0, fSecondCorner.x, fSecondCorner.y);
-
- return applyFrame.bind(null, fFirstCorner, fSecondCorner, fFirstCornerAngle, fSecondCornerAngle);
- };
- })();
-
- // https://math.stackexchange.com/questions/2223691/intersect-2-lines-at-the-same-ratio-through-a-point
- const snapZoom = (() => {
- const isAbove = (x, y, m, c) => (m * x + c) < y;
-
- const getPSecond = (low, high) => 1 - (low / high);
- const getPFirst = (low, high, target) => (target - low) / (high - low);
-
- const getProgressed = (p, [fromX, fromY], [toX, toY]) => [p * (toX - fromX) + fromX, p * (toY - fromY) + fromY];
-
- const getFlipped = (first, second, flipX, flipY) => {
- const flippedFirst = [];
- const flippedSecond = [];
- const corner = [];
-
- if (flipX) {
- flippedFirst[0] = -first.x;
- flippedSecond[0] = -second.x;
- corner[0] = -0.5;
- } else {
- flippedFirst[0] = first.x;
- flippedSecond[0] = second.x;
- corner[0] = 0.5;
- }
-
- if (flipY) {
- flippedFirst[1] = -first.y;
- flippedSecond[1] = -second.y;
- corner[1] = -0.5;
- } else {
- flippedFirst[1] = first.y;
- flippedSecond[1] = second.y;
- corner[1] = 0.5;
- }
-
- return [flippedFirst, flippedSecond, corner];
- };
-
- const getIntersectPSecond = ([[from0X, from0Y], [to0X, to0Y]], [[from1X, from1Y], [to1X, to1Y]], doFlip) => {
- const x = Math.abs(midPoint.x);
- const y = Math.abs(midPoint.y);
-
- const d = to0Y;
- const e = from0Y;
- const f = to0X;
- const g = from0X;
- const h = to1Y;
- const i = from1Y;
- const j = to1X;
- const k = from1X;
-
- const a = d * j - d * k - j * e + e * k - h * f + h * g + i * f - i * g;
- 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;
- const c = k * e - e * x - k * y - g * i + i * x + g * y;
-
- return (doFlip ? (-b - Math.sqrt(b * b - 4 * a * c)) : (-b + Math.sqrt(b * b - 4 * a * c))) / (2 * a);
- };
-
- const applyZoomPairSecond = ([z, ...pair], doFlip) => {
- const p = getIntersectPSecond(...pair, doFlip);
-
- if (p >= 0) {
- zoom = p >= 1 ? Number.MAX_SAFE_INTEGER : (z / (1 - p));
-
- return true;
- }
-
- return false;
- };
-
- const applyZoomPairFirst = ([z0, z1, ...pair], doFlip) => {
- const p = getIntersectPSecond(...pair, doFlip);
-
- if (p >= 0) {
- zoom = p * (z1 - z0) + z0;
-
- return true;
- }
-
- return false;
- };
-
- return (first0, second0, first1, second1) => {
- const getPairings = (flipX0, flipY0, flipX1, flipY1) => {
- const [flippedFirst0, flippedSecond0, corner0] = getFlipped(first0, second0, flipX0, flipY0);
- const [flippedFirst1, flippedSecond1, corner1] = getFlipped(first1, second1, flipX1, flipY1);
-
- if (second0.z > second1.z) {
- const progressedHigh = getProgressed(getPSecond(second1.z, second0.z), flippedSecond1, corner1);
- const pairHigh = [
- second0.z,
- [flippedSecond0, corner0],
- [progressedHigh, corner1],
- ];
-
- if (second1.z > first0.z) {
- const progressedLow = getProgressed(getPFirst(first0.z, second0.z, second1.z), flippedFirst0, flippedSecond0);
-
- return [
- pairHigh,
- [
- second1.z,
- second0.z,
- [progressedLow, flippedSecond0],
- [flippedSecond1, progressedHigh],
- ],
- ];
- }
-
- const progressedLow = getProgressed(getPSecond(second1.z, first0.z), flippedSecond1, corner1);
-
- return [
- pairHigh,
- [
- first0.z,
- second0.z,
- [flippedFirst0, flippedSecond0],
- [progressedLow, progressedHigh],
- ],
- ];
- }
-
- const progressedHigh = getProgressed(getPSecond(second0.z, second1.z), flippedSecond0, corner0);
- const pairHigh = [
- second1.z,
- [progressedHigh, corner0],
- [flippedSecond1, corner1],
- ];
-
- if (second0.z > first1.z) {
- const progressedLow = getProgressed(getPFirst(first1.z, second1.z, second0.z), flippedFirst1, flippedSecond1);
-
- return [
- pairHigh,
- [
- second0.z,
- second1.z,
- [progressedLow, flippedSecond1],
- [flippedSecond0, progressedHigh],
- ],
- ];
- }
-
- const progressedLow = getProgressed(getPSecond(second0.z, first1.z), flippedSecond0, corner0);
-
- return [
- pairHigh,
- [
- first1.z,
- second1.z,
- [flippedFirst1, flippedSecond1],
- [progressedLow, progressedHigh],
- ],
- ];
- };
-
- const [pair0, pair1, doFlip = false] = (() => {
- const doInvert = (midPoint.x >= 0) === (midPoint.y < 0);
-
- if (doInvert) {
- const m = (second0.y - 0.5) / (second0.x - 0.5);
- const c = 0.5 - m * 0.5;
-
- if (isAbove(Math.abs(midPoint.x), Math.abs(midPoint.y), m, c)) {
- return [...getPairings(false, false, true, false), true];
- }
-
- return getPairings(false, false, false, true);
- }
-
- const m = (second1.y - 0.5) / (second1.x - 0.5);
- const c = 0.5 - m * 0.5;
-
- if (isAbove(Math.abs(midPoint.x), Math.abs(midPoint.y), m, c)) {
- return getPairings(true, false, false, false);
- }
-
- return [...getPairings(false, true, false, false), true];
- })();
-
- if (applyZoomPairSecond(pair0, doFlip) || applyZoomPairFirst(pair1, doFlip)) {
- return;
- }
-
- zoom = pair1[0];
- };
- })();
-
- const getZoomBoundApplyFrameGetter = (() => () => {
- const videoWidth = video.clientWidth / 2;
- const videoHeight = video.clientHeight / 2;
-
- const viewportWidth = viewport.clientWidth / 2;
- const viewportHeight = viewport.clientHeight / 2;
-
- const quadrant = Math.floor(videoAngle / PI_HALVES[0]) + 3;
-
- const [xAngle, yAngle] = (() => {
- const angle = (videoAngle + PI_HALVES[3]) % PI_HALVES[0];
-
- return (quadrant % 2 === 0) ? [PI_HALVES[0] - angle, angle] : [angle, PI_HALVES[0] - angle];
- })();
-
- const progress = (xAngle / PI_HALVES[0]) * 2 - 1;
- // equivalent:
- // const progress = (yAngle / PI_HALVES[0]) * -2 + 1;
-
- const cornerAZero = (() => {
- const angleA = progress * viewportSectorAngles.side;
- const angleB = PI_HALVES[0] - angleA - yAngle;
-
- return {
- // todo broken i guess :)
- x: Math.abs((viewportWidth * Math.sin(angleA)) / (videoWidth * Math.cos(angleB))),
- y: Math.abs((viewportWidth * Math.cos(angleB)) / (videoHeight * Math.cos(angleA))),
- };
- })();
-
- const cornerBZero = (() => {
- const angleA = progress * viewportSectorAngles.base;
- const angleB = PI_HALVES[0] - angleA - yAngle;
-
- return {
- x: Math.abs((viewportHeight * Math.cos(angleA)) / (videoWidth * Math.cos(angleB))),
- y: Math.abs((viewportHeight * Math.sin(angleB)) / (videoHeight * Math.cos(angleA))),
- };
- })();
-
- const [cornerAX, cornerAY, cornerBX, cornerBY] = (() => {
- const getCornerA = (() => {
- const angleA = progress * viewportSectorAngles.side;
- const angleB = PI_HALVES[0] - angleA - yAngle;
-
- return (zoom) => {
- const h = (viewportWidth / zoom) / Math.cos(angleA);
-
- const xBound = Math.max(0, videoWidth - (Math.sin(angleB) * h));
- const yBound = Math.max(0, videoHeight - (Math.cos(angleB) * h));
-
- return {
- x: xBound / video.clientWidth,
- y: yBound / video.clientHeight,
- };
- };
- })();
-
- const getCornerB = (() => {
- const angleA = progress * viewportSectorAngles.base;
- const angleB = PI_HALVES[0] - angleA - yAngle;
-
- return (zoom) => {
- const h = (viewportHeight / zoom) / Math.cos(angleA);
-
- const xBound = Math.max(0, videoWidth - (Math.cos(angleB) * h));
- const yBound = Math.max(0, videoHeight - (Math.sin(angleB) * h));
-
- return {
- x: xBound / video.clientWidth,
- y: yBound / video.clientHeight,
- };
- };
- })();
-
- return [
- getCornerA(cornerAZero.x),
- getCornerA(cornerAZero.y),
- getCornerB(cornerBZero.x),
- getCornerB(cornerBZero.y),
- ];
- })();
-
- const cornerAVars = cornerAZero.x < cornerAZero.y ?
- [{z: cornerAZero.x, ...cornerAX}, {z: cornerAZero.y, ...cornerAY}] :
- [{z: cornerAZero.y, ...cornerAY}, {z: cornerAZero.x, ...cornerAX}];
-
- const cornerBVars = cornerBZero.x < cornerBZero.y ?
- [{z: cornerBZero.x, ...cornerBX}, {z: cornerBZero.y, ...cornerBY}] :
- [{z: cornerBZero.y, ...cornerBY}, {z: cornerBZero.x, ...cornerBX}];
-
- if (quadrant % 2 === 0) {
- return [
- getBoundApplyFrame.bind(null, ...cornerAVars, ...cornerBVars),
- snapZoom.bind(null, ...cornerAVars, ...cornerBVars),
- ];
- }
-
- return [
- getBoundApplyFrame.bind(null, ...cornerBVars, ...cornerAVars),
- snapZoom.bind(null, ...cornerBVars, ...cornerAVars),
- ];
- })();
-
- const handlers = [
- () => {
- applyMidPoint();
- },
- (doZoom, ratio) => {
- if (doZoom) {
- applyZoom();
- }
-
- const bound = 0.5 + (ratio - 0.5) / zoom;
-
- midPoint.x = Math.max(-bound, Math.min(bound, midPoint.x));
- midPoint.y = Math.max(-bound, Math.min(bound, midPoint.y));
-
- applyMidPoint();
- },
- (() => {
- let priorTheta, priorZoom, getZoomBoundApplyFrame, boundSnapZoom, boundApplyFrame;
-
- return (doZoom) => {
- if (videoAngle !== priorTheta) {
- [getZoomBoundApplyFrame, boundSnapZoom] = getZoomBoundApplyFrameGetter();
- boundApplyFrame = getZoomBoundApplyFrame();
-
- priorTheta = videoAngle;
- priorZoom = zoom;
- } else if (!doZoom && zoom !== priorZoom) {
- boundApplyFrame = getZoomBoundApplyFrame();
-
- priorZoom = zoom;
- }
-
- if (doZoom) {
- boundSnapZoom();
-
- applyZoom();
-
- ensureFramed();
-
- return;
- }
-
- boundApplyFrame();
-
- applyMidPoint();
- };
- })(),
- ];
-
- return (doZoom = false) => {
- const {panLimit} = $config.get();
-
- return handlers[panLimit.type](doZoom, panLimit.value);
- };
- })();
-
- const applyRotation = () => {
- // Conversion from anticlockwise rotation from the x-axis to clockwise rotation from the y-axis
- video.style.setProperty('rotate', `${PI_HALVES[0] - videoAngle}rad`);
- };
-
- const rotate = (change) => {
- videoAngle = (videoAngle + change) % PI_HALVES[3];
-
- if (videoAngle > PI_HALVES[0]) {
- videoAngle -= PI_HALVES[3];
- } else if (videoAngle <= -PI_HALVES[2]) {
- videoAngle += PI_HALVES[3];
- }
-
- applyRotation();
-
- // for fit-content zoom
- applyZoom();
- };
-
- const actions = {
- crop: new function() {
- const currentCrop = {};
- let handle;
-
- class Button {
- // allowance for rounding errors
- static ALLOWANCE_HANDLE = 0.0001;
-
- static CLASS_HANDLE = 'ytvc-crop-handle';
- static CLASS_EDGES = {
- left: 'ytvc-crop-left',
- top: 'ytvc-crop-top',
- right: 'ytvc-crop-right',
- bottom: 'ytvc-crop-bottom',
- };
-
- static OPPOSITES = {
- left: 'right',
- right: 'left',
-
- top: 'bottom',
- bottom: 'top',
- };
-
- callbacks = [];
-
- element = document.createElement('div');
-
- constructor(...edges) {
- this.edges = edges;
-
- this.isHandle = true;
-
- this.element.style.position = 'absolute';
- this.element.style.pointerEvents = 'all';
-
- for (const edge of edges) {
- this.element.style[edge] = '0';
-
- this.element.classList.add(Button.CLASS_EDGES[edge]);
-
- this.element.style.setProperty(`border-${Button.OPPOSITES[edge]}-width`, '1px');
- }
-
- this.element.addEventListener('contextmenu', (event) => {
- event.stopPropagation();
- event.preventDefault();
-
- this.reset(false);
- });
-
- this.element.addEventListener('pointerdown', (() => {
- const getDragListener = (event, target) => {
- const getWidth = (() => {
- if (this.edges.includes('left')) {
- const position = this.element.clientWidth - event.offsetX;
-
- return ({offsetX}) => offsetX + position;
- }
-
- const position = target.offsetWidth + event.offsetX;
-
- return ({offsetX}) => position - offsetX;
- })();
-
- const getHeight = (() => {
- if (this.edges.includes('top')) {
- const position = this.element.clientHeight - event.offsetY;
-
- return ({offsetY}) => offsetY + position;
- }
-
- const position = target.offsetHeight + event.offsetY;
-
- return ({offsetY}) => position - offsetY;
- })();
-
- return (event) => {
- this.set({
- width: getWidth(event) / video.clientWidth,
- height: getHeight(event) / video.clientHeight,
- });
- };
- };
-
- const clickListener = ({offsetX, offsetY, target}) => {
- this.set({
- width: (this.edges.includes('left') ? offsetX : target.clientWidth - offsetX) / video.clientWidth,
- height: (this.edges.includes('top') ? offsetY : target.clientHeight - offsetY) / video.clientHeight,
- }, false);
- };
-
- return async (event) => {
- if (event.buttons === 1) {
- const target = this.element.parentElement;
-
- await handleMouseDown(event, clickListener, getDragListener(event, target), target);
-
- this.updateCounterpart();
- }
- };
- })());
- }
-
- notify(property) {
- for (const callback of this.callbacks) {
- callback(this.element.style[property], property);
- }
- }
-
- set isHandle(value) {
- this._isHandle = value;
-
- this.element.classList[value ? 'add' : 'remove'](Button.CLASS_HANDLE);
- }
-
- get isHandle() {
- return this._isHandle;
- }
-
- reset() {
- this.isHandle = true;
-
- for (const edge of this.edges) {
- currentCrop[edge] = 0;
- }
- }
- }
-
- class EdgeButton extends Button {
- constructor(edge) {
- super(edge);
-
- this.edge = edge;
- }
-
- updateCounterpart() {
- if (this.counterpart.isHandle) {
- this.counterpart.setHandle();
- }
- }
-
- setCrop(value = 0) {
- currentCrop[this.edge] = value;
- }
- }
-
- class SideButton extends EdgeButton {
- flow() {
- const {top, bottom} = currentCrop;
-
- let size = 100;
-
- if (top <= Button.ALLOWANCE_HANDLE) {
- size -= handle;
-
- this.element.style.top = `${handle}%`;
- } else {
- size -= top * 100;
-
- this.element.style.top = `${top * 100}%`;
- }
-
- if (bottom <= Button.ALLOWANCE_HANDLE) {
- size -= handle;
- } else {
- size -= bottom * 100;
- }
-
- this.element.style.height = `${Math.max(0, size)}%`;
- }
-
- setBounds(counterpart, components) {
- this.counterpart = components[counterpart];
-
- components.top.callbacks.push(() => {
- this.flow();
- });
-
- components.bottom.callbacks.push(() => {
- this.flow();
- });
- }
-
- notify() {
- super.notify('width');
- }
-
- setHandle(doNotify = true) {
- this.element.style.width = `${Math.min((1 - currentCrop[this.counterpart.edge]) * 100, handle)}%`;
-
- if (doNotify) {
- this.notify();
- }
- }
-
- set({width}, doUpdateCounterpart = true) {
- const wasHandle = this.isHandle;
-
- this.isHandle = width <= Button.ALLOWANCE_HANDLE;
-
- if (wasHandle !== this.isHandle) {
- this.flow();
- }
-
- if (doUpdateCounterpart) {
- this.updateCounterpart();
- }
-
- if (this.isHandle) {
- this.setCrop();
-
- this.setHandle();
-
- return;
- }
-
- const size = Math.min(1 - currentCrop[this.counterpart.edge], width);
-
- this.setCrop(size);
-
- this.element.style.width = `${size * 100}%`;
-
- this.notify();
- }
-
- reset(isGeneral = true) {
- super.reset();
-
- if (isGeneral) {
- this.element.style.top = `${handle}%`;
- this.element.style.height = `${100 - handle * 2}%`;
- this.element.style.width = `${handle}%`;
-
- return;
- }
-
- this.flow();
-
- this.setHandle();
-
- this.updateCounterpart();
- }
- }
-
- class BaseButton extends EdgeButton {
- flow() {
- const {left, right} = currentCrop;
-
- let size = 100;
-
- if (left <= Button.ALLOWANCE_HANDLE) {
- size -= handle;
-
- this.element.style.left = `${handle}%`;
- } else {
- size -= left * 100;
-
- this.element.style.left = `${left * 100}%`;
- }
-
- if (right <= Button.ALLOWANCE_HANDLE) {
- size -= handle;
- } else {
- size -= right * 100;
- }
-
- this.element.style.width = `${Math.max(0, size)}%`;
- }
-
- setBounds(counterpart, components) {
- this.counterpart = components[counterpart];
-
- components.left.callbacks.push(() => {
- this.flow();
- });
-
- components.right.callbacks.push(() => {
- this.flow();
- });
- }
-
- notify() {
- super.notify('height');
- }
-
- setHandle(doNotify = true) {
- this.element.style.height = `${Math.min((1 - currentCrop[this.counterpart.edge]) * 100, handle)}%`;
-
- if (doNotify) {
- this.notify();
- }
- }
-
- set({height}, doUpdateCounterpart = false) {
- const wasHandle = this.isHandle;
-
- this.isHandle = height <= Button.ALLOWANCE_HANDLE;
-
- if (wasHandle !== this.isHandle) {
- this.flow();
- }
-
- if (doUpdateCounterpart) {
- this.updateCounterpart();
- }
-
- if (this.isHandle) {
- this.setCrop();
-
- this.setHandle();
-
- return;
- }
-
- const size = Math.min(1 - currentCrop[this.counterpart.edge], height);
-
- this.setCrop(size);
-
- this.element.style.height = `${size * 100}%`;
-
- this.notify();
- }
-
- reset(isGeneral = true) {
- super.reset();
-
- if (isGeneral) {
- this.element.style.left = `${handle}%`;
- this.element.style.width = `${100 - handle * 2}%`;
- this.element.style.height = `${handle}%`;
-
- return;
- }
-
- this.flow();
-
- this.setHandle();
-
- this.updateCounterpart();
- }
- }
-
- class CornerButton extends Button {
- static CLASS_NAME = 'ytvc-crop-corner';
-
- constructor(sectors, ...edges) {
- super(...edges);
-
- this.element.classList.add(CornerButton.CLASS_NAME);
-
- this.sectors = sectors;
-
- for (const sector of sectors) {
- sector.callbacks.push(this.flow.bind(this));
- }
- }
-
- flow() {
- let isHandle = true;
-
- if (this.sectors[0].isHandle) {
- this.element.style.width = `${Math.min((1 - currentCrop[this.sectors[0].counterpart.edge]) * 100, handle)}%`;
- } else {
- this.element.style.width = `${currentCrop[this.edges[0]] * 100}%`;
-
- isHandle = false;
- }
-
- if (this.sectors[1].isHandle) {
- this.element.style.height = `${Math.min((1 - currentCrop[this.sectors[1].counterpart.edge]) * 100, handle)}%`;
- } else {
- this.element.style.height = `${currentCrop[this.edges[1]] * 100}%`;
-
- isHandle = false;
- }
-
- this.isHandle = isHandle;
- }
-
- updateCounterpart() {
- for (const sector of this.sectors) {
- sector.updateCounterpart();
- }
- }
-
- set(size) {
- for (const sector of this.sectors) {
- sector.set(size);
- }
- }
-
- reset(isGeneral = true) {
- this.isHandle = true;
-
- this.element.style.width = `${handle}%`;
- this.element.style.height = `${handle}%`;
-
- if (isGeneral) {
- return;
- }
-
- for (const sector of this.sectors) {
- sector.reset(false);
- }
- }
- }
-
- this.CODE = 'crop';
-
- this.CLASS_ABLE = 'ytvc-action-able-crop';
-
- const container = document.createElement('div');
-
- // todo ditch the containers object
- container.style.width = container.style.height = 'inherit';
-
- containers.foreground.append(container);
-
- this.onRightClick = (event) => {
- if (event.target.parentElement.id === container.id) {
- return;
- }
-
- event.stopPropagation();
- event.preventDefault();
-
- if (stopDrag) {
- return;
- }
-
- for (const component of Object.values(this.components)) {
- component.reset(true);
- }
- };
-
- this.onScroll = (event) => {
- const {speeds} = $config.get();
-
- event.stopImmediatePropagation();
- event.preventDefault();
-
- if (event.deltaY === 0) {
- return;
- }
-
- const increment = event.deltaY * speeds.crop / zoom;
- const {top, left, right, bottom} = currentCrop;
-
- this.components.top.set({height: top + Math.min((1 - top - bottom) / 2, increment)});
- this.components.left.set({width: left + Math.min((1 - left - right) / 2, increment)});
-
- this.components.bottom.set({height: bottom + increment});
- this.components.right.set({width: right + increment});
- };
-
- this.onMouseDown = (() => {
- const getDragListener = ({offsetX, offsetY}) => {
- const {multipliers} = $config.get();
-
- const {top, left, right, bottom} = currentCrop;
-
- const clampX = (value) => Math.max(-left, Math.min(right, value));
- const clampY = (value) => Math.max(-top, Math.min(bottom, value));
-
- return (event) => {
- const incrementX = clampX((offsetX - event.offsetX) * multipliers.crop / video.clientWidth);
- const incrementY = clampY((offsetY - event.offsetY) * multipliers.crop / video.clientHeight);
-
- this.components.top.set({height: top + incrementY});
- this.components.left.set({width: left + incrementX});
- this.components.bottom.set({height: bottom - incrementY});
- this.components.right.set({width: right - incrementX});
- };
- };
-
- const clickListener = () => {
- const {top, left, right, bottom} = currentCrop;
-
- zoom = getFitContentZoom(1 - left - right, 1 - top - bottom);
-
- applyZoom();
-
- midPoint.x = (left - right) / 2;
- midPoint.y = (bottom - top) / 2;
-
- ensureFramed();
- };
-
- return (event) => {
- if (event.buttons === 1) {
- handleMouseDown(event, clickListener, getDragListener(event), container);
- }
- };
- })();
-
- this.components = {
- top: new BaseButton('top'),
- right: new SideButton('right'),
- bottom: new BaseButton('bottom'),
- left: new SideButton('left'),
- };
-
- this.components.top.setBounds('bottom', this.components);
- this.components.right.setBounds('left', this.components);
- this.components.bottom.setBounds('top', this.components);
- this.components.left.setBounds('right', this.components);
-
- this.components.topLeft = new CornerButton([this.components.left, this.components.top], 'left', 'top');
- this.components.topRight = new CornerButton([this.components.right, this.components.top], 'right', 'top');
- this.components.bottomLeft = new CornerButton([this.components.left, this.components.bottom], 'left', 'bottom');
- this.components.bottomRight = new CornerButton([this.components.right, this.components.bottom], 'right', 'bottom');
-
- container.append(...Object.values(this.components).map(({element}) => element));
-
- const cropRule = new css.Toggleable();
-
- this.onInactive = () => {
- const {top, left, right, bottom} = currentCrop;
-
- Object.assign(crop, currentCrop);
-
- background.handleViewChange();
-
- cropRule.add(
- `${SELECTOR_VIDEO}:not(.${this.CLASS_ABLE} *):not(.${button.CLASS_PEEK} *)`,
- ['clip-path', `inset(${top * 100}% ${right * 100}% ${bottom * 100}% ${left * 100}%)`],
- );
-
- addListeners(this, false);
-
- background.reset();
- };
-
- this.onActive = () => {
- const config = $config.get().crop;
-
- handle = config.handle / Math.max(zoom, 1);
-
- for (const component of [this.components.top, this.components.bottom, this.components.left, this.components.right]) {
- if (component.isHandle) {
- component.setHandle();
- }
- }
-
- Object.assign(currentCrop, crop);
-
- crop.top = crop.bottom = crop.left = crop.right = 0;
-
- addListeners(this);
-
- if (!Enabler.isHidingBg) {
- background.handleViewChange();
-
- background.reset();
- }
- };
-
- this.stop = () => {
- crop.top = crop.bottom = crop.left = crop.right = 0;
-
- for (const component of Object.values(this.components)) {
- component.reset(true);
- }
-
- cropRule.remove();
- };
-
- const draggingSelector = css.getSelector(Enabler.CLASS_DRAGGING);
-
- this.updateConfig = (() => {
- const rule = new css.Toggleable();
-
- return () => {
- Object.assign(currentCrop, crop);
-
- // set handle size
- for (const button of [this.components.left, this.components.top, this.components.right, this.components.bottom]) {
- if (button.isHandle) {
- button.setHandle();
- }
- }
-
- rule.remove();
-
- const {colour} = $config.get().crop;
- const {id} = container;
-
- rule.add(`#${id}>:hover.${Button.CLASS_HANDLE},#${id}>:not(.${Button.CLASS_HANDLE})`, ['background-color', colour.fill]);
- rule.add(`#${id}>*`, ['border-color', colour.border]);
- rule.add(`#${id}:not(${draggingSelector} *)>:not(:hover)`, ['filter', `drop-shadow(${colour.shadow} 0 0 1px)`]);
- };
- })();
-
- $config.ready.then(() => {
- this.updateConfig();
- });
-
- container.id = 'ytvc-crop-container';
-
- (() => {
- const {id} = container;
-
- css.add(`${css.getSelector(Enabler.CLASS_DRAGGING)} #${id}`, ['cursor', 'grabbing']);
- css.add(`${css.getSelector(Enabler.CLASS_ABLE)} #${id}`, ['cursor', 'grab']);
- css.add(`#${id}>:not(${draggingSelector} .${Button.CLASS_HANDLE})`, ['border-style', 'solid']);
- css.add(`${draggingSelector} #${id}>.${Button.CLASS_HANDLE}`, ['filter', 'none']);
- // in fullscreen, 16:9 videos get an offsetLeft of 1px on my 16:9 monitor
- // therefore I'm extending left-side buttons by 1px so that they still reach the edge of the screen
- css.add(`#${id}>.${Button.CLASS_EDGES.left}`, ['margin-left', '-1px'], ['padding-left', '1px']);
-
- for (const [side, sideClass] of Object.entries(Button.CLASS_EDGES)) {
- css.add(
- `${draggingSelector} #${id}>.${sideClass}.${Button.CLASS_HANDLE}~.${sideClass}.${CornerButton.CLASS_NAME}`,
- [`border-${CornerButton.OPPOSITES[side]}-style`, 'none'],
- ['filter', 'none'],
- );
- }
-
- css.add(`#${id}:not(.${this.CLASS_ABLE} *)`, ['display', 'none']);
- })();
- }(),
-
- pan: new function() {
- this.CODE = 'pan';
-
- this.CLASS_ABLE = 'ytvc-action-able-pan';
-
- this.onActive = () => {
- this.updateCrosshair();
-
- addListeners(this);
- };
-
- this.onInactive = () => {
- addListeners(this, false);
- };
-
- this.updateCrosshair = (() => {
- const getRoundedString = (number, decimal = 2) => {
- const raised = `${Math.round(number * Math.pow(10, decimal))}`.padStart(decimal + 1, '0');
-
- return `${raised.substr(0, raised.length - decimal)}.${raised.substr(raised.length - decimal)}`;
- };
-
- const getSigned = (ratio) => {
- const percent = Math.round(ratio * 100);
-
- if (percent <= 0) {
- return `${percent}`;
- }
-
- return `+${percent}`;
- };
-
- return () => {
- crosshair.text.innerText = `${getRoundedString(zoom)}×\n${getSigned(midPoint.x)}%\n${getSigned(midPoint.y)}%`;
- };
- })();
-
- this.onScroll = (event) => {
- const {speeds} = $config.get();
-
- event.stopImmediatePropagation();
- event.preventDefault();
-
- if (event.deltaY === 0) {
- return;
- }
-
- const increment = event.deltaY * speeds.zoom;
-
- if (increment > 0) {
- zoom *= 1 + increment;
- } else {
- zoom /= 1 - increment;
- }
-
- applyZoom();
-
- ensureFramed();
-
- this.updateCrosshair();
- };
-
- this.onRightClick = (event) => {
- event.stopImmediatePropagation();
- event.preventDefault();
-
- if (stopDrag) {
- return;
- }
-
- resetMidPoint();
- resetZoom();
-
- this.updateCrosshair();
- };
-
- this.onMouseDown = (() => {
- const getDragListener = () => {
- const {multipliers} = $config.get();
-
- let priorEvent;
-
- const change = {x: 0, y: 0};
-
- return ({offsetX, offsetY}) => {
- if (priorEvent) {
- change.x = (offsetX - (priorEvent.offsetX + change.x)) * multipliers.pan;
- change.y = (offsetY - (priorEvent.offsetY - change.y)) * -multipliers.pan;
-
- midPoint.x += change.x / video.clientWidth;
- midPoint.y += change.y / video.clientHeight;
-
- ensureFramed();
-
- this.updateCrosshair();
- }
-
- // events in firefox seem to lose their data after finishing propogation
- // so assigning the whole event doesn't work
- priorEvent = {offsetX, offsetY};
- };
- };
-
- const clickListener = (event) => {
- const position = {
- x: (event.offsetX / video.clientWidth) - 0.5,
- // Y increases moving down the page
- // I flip that to make trigonometry easier
- y: (-event.offsetY / video.clientHeight) + 0.5,
- };
-
- midPoint.x = position.x;
- midPoint.y = position.y;
-
- ensureFramed(true);
-
- this.updateCrosshair();
- };
-
- return (event) => {
- if (event.buttons === 1) {
- handleMouseDown(event, clickListener, getDragListener());
- }
- };
- })();
- }(),
-
- rotate: new function() {
- this.CODE = 'rotate';
-
- this.CLASS_ABLE = 'ytvc-action-able-rotate';
-
- this.onActive = () => {
- this.updateCrosshair();
-
- addListeners(this);
- };
-
- this.onInactive = () => {
- addListeners(this, false);
- };
-
- this.updateCrosshair = () => {
- const angle = PI_HALVES[0] - videoAngle;
-
- crosshair.text.innerText = `${Math.floor((PI_HALVES[0] - videoAngle) / Math.PI * 180)}°\n≈${Math.round(angle / PI_HALVES[0]) % 4 * 90}°`;
- };
-
- this.onScroll = (event) => {
- const {speeds} = $config.get();
-
- event.stopImmediatePropagation();
- event.preventDefault();
-
- rotate(speeds.rotate * event.deltaY);
-
- ensureFramed();
-
- this.updateCrosshair();
- };
-
- this.onRightClick = (event) => {
- event.stopImmediatePropagation();
- event.preventDefault();
-
- if (stopDrag) {
- return;
- }
-
- resetRotation();
-
- this.updateCrosshair();
- };
-
- this.onMouseDown = (() => {
- const getDragListener = () => {
- const {multipliers} = $config.get();
- const middleX = containers.tracker.clientWidth / 2;
- const middleY = containers.tracker.clientHeight / 2;
-
- const priorMidPoint = {...midPoint};
-
- let priorMouseTheta;
-
- return (event) => {
- const mouseTheta = getTheta(middleX, middleY, event.offsetX, event.offsetY);
-
- if (priorMouseTheta === undefined) {
- priorMouseTheta = mouseTheta;
-
- return;
- }
-
- rotate((mouseTheta - priorMouseTheta) * multipliers.rotate);
-
- // only useful for the 'Frame' panLimit
- // looks weird but it's probably useful
- midPoint.x = priorMidPoint.x;
- midPoint.y = priorMidPoint.y;
-
- ensureFramed();
-
- this.updateCrosshair();
-
- priorMouseTheta = mouseTheta;
- };
- };
-
- const clickListener = () => {
- const theta = Math.abs(videoAngle) % PI_HALVES[0];
- const progress = theta / PI_HALVES[0];
-
- rotate(Math.sign(videoAngle) * (progress < 0.5 ? -theta : (PI_HALVES[0] - theta)));
-
- ensureFramed();
-
- this.updateCrosshair();
- };
-
- return (event) => {
- if (event.buttons === 1) {
- handleMouseDown(event, clickListener, getDragListener(), containers.tracker);
- }
- };
- })();
- }(),
- };
-
- const crosshair = new function() {
- this.container = document.createElement('div');
-
- this.lines = {
- horizontal: document.createElement('div'),
- vertical: document.createElement('div'),
- };
-
- this.text = document.createElement('div');
-
- const id = 'ytvc-crosshair';
-
- this.container.id = id;
-
- css.add(`#${id}:not(${css.getSelector(actions.pan.CLASS_ABLE)} *):not(${css.getSelector(actions.rotate.CLASS_ABLE)} *)`, ['display', 'none']);
-
- this.lines.horizontal.style.position = this.lines.vertical.style.position = this.text.style.position = this.container.style.position = 'absolute';
-
- this.lines.horizontal.style.top = '50%';
- this.lines.horizontal.style.width = '100%';
-
- this.lines.vertical.style.left = '50%';
- this.lines.vertical.style.height = '100%';
-
- this.text.style.userSelect = 'none';
-
- this.container.style.top = '0';
- this.container.style.width = '100%';
- this.container.style.height = '100%';
- this.container.style.pointerEvents = 'none';
-
- this.container.append(this.lines.horizontal, this.lines.vertical, this.text);
-
- this.clip = () => {
- const {outer, inner, gap} = $config.get().crosshair;
-
- const thickness = Math.max(inner, outer);
-
- const halfWidth = viewport.clientWidth / 2;
- const halfHeight = viewport.clientHeight / 2;
- const halfGap = gap / 2;
-
- const startInner = (thickness - inner) / 2;
- const startOuter = (thickness - outer) / 2;
-
- const endInner = thickness - startInner;
- const endOuter = thickness - startOuter;
-
- this.lines.horizontal.style.clipPath = 'path(\'' +
- `M0 ${startOuter}L${halfWidth - halfGap} ${startOuter}L${halfWidth - halfGap} ${startInner}L${halfWidth + halfGap} ${startInner}L${halfWidth + halfGap} ${startOuter}L${viewport.clientWidth} ${startOuter}` +
- `L${viewport.clientWidth} ${endOuter}L${halfWidth + halfGap} ${endOuter}L${halfWidth + halfGap} ${endInner}L${halfWidth - halfGap} ${endInner}L${halfWidth - halfGap} ${endOuter}L0 ${endOuter}` +
- 'Z\')';
-
- this.lines.vertical.style.clipPath = 'path(\'' +
- `M${startOuter} 0L${startOuter} ${halfHeight - halfGap}L${startInner} ${halfHeight - halfGap}L${startInner} ${halfHeight + halfGap}L${startOuter} ${halfHeight + halfGap}L${startOuter} ${viewport.clientHeight}` +
- `L${endOuter} ${viewport.clientHeight}L${endOuter} ${halfHeight + halfGap}L${endInner} ${halfHeight + halfGap}L${endInner} ${halfHeight - halfGap}L${endOuter} ${halfHeight - halfGap}L${endOuter} 0` +
- 'Z\')';
- };
-
- this.updateConfig = (doClip = true) => {
- const {colour, outer, inner, text} = $config.get().crosshair;
- const thickness = Math.max(inner, outer);
-
- this.container.style.filter = `drop-shadow(${colour.shadow} 0 0 1px)`;
-
- this.lines.horizontal.style.translate = `0 -${thickness / 2}px`;
- this.lines.vertical.style.translate = `-${thickness / 2}px 0`;
-
- this.lines.horizontal.style.height = this.lines.vertical.style.width = `${thickness}px`;
-
- this.text.style.color = this.lines.horizontal.style.backgroundColor = this.lines.vertical.style.backgroundColor = colour.fill;
-
- this.text.style.font = text.font;
- this.text.style.left = `${text.position.x}%`;
- this.text.style.top = `${text.position.y}%`;
- this.text.style.transform = `translate(${text.translate.x}%,${text.translate.y}%) translate(${text.offset.x}px,${text.offset.y}px)`;
- this.text.style.textAlign = text.align;
- this.text.style.lineHeight = text.height;
-
- if (doClip) {
- this.clip();
- }
- };
-
- $config.ready.then(() => {
- this.updateConfig(false);
- });
- }();
-
- const observer = new function() {
- const onVideoEnd = () => {
- if (isOn) {
- stop();
- }
-
- video.addEventListener('play', () => {
- if (isOn) {
- start();
- }
- }, {once: true});
- };
-
- const onResolutionChange = () => {
- background.handleSizeChange?.();
- };
-
- const styleObserver = new MutationObserver((() => {
- const properties = ['top', 'left', 'width', 'height', 'scale', 'rotate', 'translate'];
-
- let priorStyle;
-
- return () => {
- // mousemove events on video with ctrlKey=true trigger this but have no effect
- if (video.style.cssText === priorStyle) {
- return;
- }
-
- priorStyle = video.style.cssText;
-
- for (const property of properties) {
- containers.background.style[property] = video.style[property];
- containers.foreground.style[property] = video.style[property];
- }
-
- background.handleViewChange();
- };
- })());
-
- const videoObserver = new ResizeObserver(() => {
- setViewportAngles();
-
- background.handleSizeChange?.();
- });
-
- const viewportObserver = new ResizeObserver(() => {
- setViewportAngles();
-
- crosshair.clip();
- });
-
- this.start = () => {
- video.addEventListener('ended', onVideoEnd);
-
- video.addEventListener('resize', onResolutionChange);
-
- styleObserver.observe(video, {attributes: true, attributeFilter: ['style']});
-
- videoObserver.observe(video);
-
- viewportObserver.observe(viewport);
-
- background.handleViewChange();
- };
-
- this.stop = async (immediate) => {
- if (!immediate) {
- // delay stopping to reset observed elements
- await new Promise((resolve) => {
- window.setTimeout(resolve, 0);
- });
- }
-
- video.removeEventListener('ended', onVideoEnd);
- video.removeEventListener('resize', onResolutionChange);
-
- styleObserver.disconnect();
- videoObserver.disconnect();
- viewportObserver.disconnect();
- };
- }();
-
- const kill = () => {
- stopDrag?.();
-
- css.tag(Enabler.CLASS_ABLE, false);
-
- for (const {CLASS_ABLE} of Object.values(actions)) {
- css.tag(CLASS_ABLE, false);
- }
-
- Enabler.setActive(false);
- };
-
- const stop = (immediate = false) => {
- kill();
-
- observer.stop?.(immediate);
-
- background.stop();
- actions.crop.stop();
-
- containers.background.remove();
- containers.foreground.remove();
- containers.tracker.remove();
- crosshair.container.remove();
-
- resetMidPoint();
- resetZoom();
- resetRotation();
- };
-
- const start = () => {
- observer.start();
-
- background.start();
-
- viewport.append(containers.background, containers.foreground, containers.tracker, crosshair.container);
-
- // User may have a custom minimum zoom greater than 1
- applyZoom();
-
- Enabler.handleChange();
- };
-
- const updateConfigs = () => {
- Enabler.updateConfig();
- actions.crop.updateConfig();
- crosshair.updateConfig();
- };
-
- const button = new function() {
- this.element = document.createElement('button');
-
- this.element.classList.add('ytp-button', 'ytp-settings-button');
- this.element.title = 'View Controls';
-
- (() => {
- // SVG
- const box = 36;
- const center = box / 2;
-
- // Circle
- const radius = 8.5;
- const diameter = radius * 2;
-
- const ns = 'http://www.w3.org/2000/svg';
-
- const svg = document.createElementNS(ns, 'svg');
-
- svg.setAttribute('viewBox', `0 0 ${box} ${box}`);
- svg.style.rotate = '20deg';
- svg.style.stroke = 'white';
- svg.style.fill = 'white';
- svg.style.height = '100%';
- svg.style.width = '100%';
- svg.style.filter = 'drop-shadow(0px 0px 1px #444)';
-
- const inner = document.createElementNS(ns, 'circle');
-
- inner.setAttribute('r', `${radius}`);
- inner.setAttribute('cx', `${center}`);
- inner.setAttribute('cy', `${center}`);
- inner.style.transition = '0.5s clip-path';
- inner.style.strokeWidth = '0';
-
- const outer = document.createElementNS(ns, 'circle');
-
- outer.setAttribute('r', `${radius + 1.5}`);
- outer.setAttribute('cx', `${center}`);
- outer.setAttribute('cy', `${center}`);
- outer.style.fill = 'none';
-
- svg.append(inner, outer);
-
- this.element.append(svg);
-
- this.update = (() => {
- const getPath = (stroke, offset) => 'path("' +
- `M-0.5 -0.5L-0.5 ${diameter + 1}L${diameter + 1} ${diameter + 1}L${diameter + 1} -0.5Z` +
- `M0 ${radius - stroke / 2}l${radius - stroke / 2 + offset / 2} 0l0 ${-radius}l${stroke} 0l0 ${radius}l${radius} 0l0 ${stroke}l${-radius - offset} 0l0 ${radius}l${-stroke} 0l0 ${-radius}l${-radius} 0Z` +
- '")';
-
- const pathOn = getPath(3, 2);
- const pathOff = getPath(0, 0);
-
- return () => inner.style.clipPath = isOn ? pathOn : pathOff;
- })();
- })();
-
- this.edit = async () => {
- await $config.edit();
-
- background.reset();
-
- updateConfigs();
-
- viewport.focus();
-
- ensureFramed();
- applyZoom();
- };
-
- this.handleKeyChange = () => {
- if (!viewport) {
- return;
- }
-
- const {keys} = $config.get();
- const doClick = Enabler.keys.isSupersetOf(keys.on);
- const doConfig = Enabler.keys.isSupersetOf(keys.config) && (!doClick || keys.on.size < keys.config.size);
-
- if (doConfig) {
- this.edit();
-
- kill();
- } else if (doClick) {
- this.element.click();
- }
- };
-
- const KEY = 'YTVC_ON';
-
- this.element.addEventListener('click', (event) => {
- event.stopPropagation();
- event.preventDefault();
-
- isOn = !isOn;
-
- GM.setValue(KEY, isOn);
-
- this.update();
-
- if (!isOn) {
- stop();
- } else if (!video.ended) {
- start();
- }
- });
-
- this.element.addEventListener('contextmenu', (event) => {
- event.stopPropagation();
- event.preventDefault();
-
- this.edit();
- });
-
- this.CLASS_PEEK = 'ytvc-peek';
-
- this.element.addEventListener('pointerover', () => {
- if (!$config.get().peek) {
- return;
- }
-
- video.style.removeProperty('translate');
- video.style.removeProperty('rotate');
- video.style.removeProperty('scale');
-
- css.tag(this.CLASS_PEEK);
- });
-
- this.element.addEventListener('pointerleave', () => {
- if (!$config.get().peek) {
- return;
- }
-
- applyMidPoint();
- applyRotation();
- applyZoom();
-
- css.tag(this.CLASS_PEEK, false);
- });
-
- this.init = async () => {
- const sibling = video.parentElement.parentElement.querySelector('.ytp-subtitles-button');
-
- isOn = false;
-
- this.update();
-
- sibling.parentElement.insertBefore(this.element, sibling);
-
- if (await GM.getValue(KEY, true)) {
- this.element.click();
- }
- };
- }();
-
- document.body.addEventListener('yt-navigate-finish', async () => {
- if (viewport) {
- stop(true);
- }
-
- viewport = document.querySelector(SELECTOR_VIEWPORT);
-
- if (!viewport) {
- return;
- }
-
- try {
- await $config.ready;
- } catch (error) {
- if (!$config.reset || !window.confirm(`${error.message}\n\nWould you like to erase your data?`)) {
- console.error(error);
-
- return;
- }
-
- await $config.reset();
-
- updateConfigs();
- }
-
- video = viewport.querySelector(SELECTOR_VIDEO);
- altTarget = document.querySelector(SELECTOR_ROOT);
-
- // wait for video dimensions for background initialisation
- if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
- await new Promise((resolve) => {
- video.addEventListener('loadedmetadata', resolve, {once: true});
- });
- }
-
- containers.foreground.style.zIndex = crosshair.container.style.zIndex = video.parentElement.computedStyleMap?.().get('z-index').value ?? 10;
- crosshair.clip();
- setViewportAngles();
-
- button.init();
- });
-
- // needs to be done after things are initialised
- (() => {
- const handleKeyChange = (key, isDown) => {
- if (Enabler.keys.has(key) === isDown) {
- return;
- }
-
- Enabler.keys[isDown ? 'add' : 'delete'](key);
-
- button.handleKeyChange();
-
- if (isOn) {
- Enabler.handleChange();
- }
- };
-
- document.addEventListener('keydown', ({key}) => handleKeyChange(key.toLowerCase(), true));
- document.addEventListener('keyup', ({key}) => handleKeyChange(key.toLowerCase(), false));
- })();
-
- window.addEventListener('blur', () => {
- Enabler.keys.clear();
-
- Enabler.handleChange();
- });