YouTube View Controls

Zoom, rotate & crop YouTube videos.

目前为 2025-01-18 提交的版本。查看 最新版本

// ==UserScript==
// @name        YouTube View Controls
// @version     0.0
// @description Zoom, rotate & crop YouTube videos.
// @author      Callum Latham
// @namespace   https://gf.qytechs.cn/users/696211-ctl2
// @license     MIT
// @match       *://www.youtube.com/*
// @match       *://youtube.com/*
// @exclude     *://www.youtube.com/embed/*
// @exclude     *://youtube.com/embed/*
// @require     https://update.gf.qytechs.cn/scripts/446506/1522829/%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 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: (() => {
								const shift = navigator.userAgent.includes('Firefox') ? '\\' : 'Shift';
								const seed = {
									value: '',
									listeners: {
										keydown: (event) => {
											switch (event.key) {
												case 'Enter':
												case 'Escape':
													return;
											}
											
											event.preventDefault();
											
											event.target.value = event.key;
											
											event.target.dispatchEvent(new InputEvent('input'));
										},
									},
								};
								
								const getKeys = (children) => new Set(children.map(({value}) => value.toLowerCase()));
								
								const getNode = (label, keys, get) => ({
									label,
									seed,
									children: keys.map((value) => ({...seed, value})),
									get,
								});
								
								return [
									{
										label: 'Actions',
										get: (_, [toggle, ...controls]) => Object.assign(...controls.map(({id, keys}) => ({
											[id]: {
												toggle,
												keys,
											},
										}))),
										children: [
											{
												label: 'Toggle?',
												value: false,
												get: ({value}) => (value),
											},
											...[
												['Pan / Zoom', ['Control'], 'pan'],
												['Rotate', [shift], 'rotate'],
												['Crop', ['Control', shift], 'crop'],
											].map(([label, keys, id]) => getNode(label, keys, ({children}) => ({id, keys: getKeys(children)}))),
										],
									},
									getNode('Reset', ['x'], ({children}) => ({reset: {keys: getKeys(children)}})),
									getNode('Configure', ['Alt', 'x'], ({children}) => ({config: {keys: getKeys(children)}})),
								];
							})(),
						},
						{
							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: '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 video;
let altTarget;
let viewport;
let cinematics;

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.setActive = (action) => {
		const {active, keys} = $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;
			this.toggled = keys[action.CODE].toggle;
			
			action.onActive?.();
			
			if (active.pause && !video.paused) {
				video.pause();
				
				this.didPause = true;
			}
			
			return;
		}
		
		if (this.didPause) {
			video.play();
			
			this.didPause = false;
		}
		
		this.activeAction = this.toggled = undefined;
	};
	
	this.handleChange = () => {
		if (!video || stopDrag || video.ended) {
			return;
		}
		
		const {keys} = $config.get();
		
		let activeAction;
		let keyCount = 0;
		
		for (const action of Object.values(actions)) {
			if (!this.keys.isSupersetOf(keys[action.CODE].keys) || keyCount >= keys[action.CODE].keys.size) {
				if ('CLASS_ABLE' in action) {
					css.tag(action.CLASS_ABLE, false);
				}
				
				continue;
			}
			
			if (activeAction && 'CLASS_ABLE' in activeAction) {
				css.tag(activeAction.CLASS_ABLE, false);
			}
			
			activeAction = action;
			keyCount = keys[action.CODE].keys.size;
		}
		
		if (!activeAction && this.toggled) {
			css.tag(this.activeAction.CLASS_ABLE);
			
			return;
		}
		
		if (activeAction === this.activeAction) {
			if (!this.toggled) {
				return;
			}
			
			activeAction = undefined;
		}
		
		if (activeAction) {
			if ('CLASS_ABLE' in activeAction) {
				css.tag(activeAction.CLASS_ABLE);
				
				css.tag(this.CLASS_ABLE);
				
				this.setActive(activeAction);
				
				return;
			}
			
			this.activeAction?.onInactive?.();
			
			activeAction.onActive();
			
			this.activeAction = activeAction;
		}
		
		css.tag(this.CLASS_ABLE, false);
		
		this.setActive(false);
	};
	
	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);
		
		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');
			}
			
			// 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();
			
			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;
	
	video.style.removeProperty('translate');
};

const resetZoom = () => {
	zoom = 1;
	
	video.style.removeProperty('scale');
};

const resetRotation = () => {
	videoAngle = PI_HALVES[0];
	
	video.style.removeProperty('rotate');
	
	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) => {
		constrain();
		
		if (doApply) {
			video.style.setProperty('scale', `${zoom}`);
			
			delete actions.reset.prior;
		}
		
		return zoom;
	};
})();

const applyMidPoint = () => {
	const {x, y} = getRotated(midPoint.x, midPoint.y);
	
	video.style.setProperty('translate', `${-x * zoom * 100}% ${y * zoom * 100}%`);
	
	delete actions.reset.prior;
};

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`);
	
	delete actions.reset.prior;
};

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 = () => {
				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));
				
				let priorEvent;
				
				return ({offsetX, offsetY}) => {
					if (!priorEvent) {
						priorEvent = {offsetX, offsetY};
						
						return;
					}
					
					const incrementX = clampX((priorEvent.offsetX - offsetX) * multipliers.crop / video.clientWidth);
					const incrementY = clampY((priorEvent.offsetY - 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(), 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.apply = ({top, left, right, bottom}) => {
			cropRule.add(
				`${SELECTOR_VIDEO}:not(.${this.CLASS_ABLE} *)`,
				['clip-path', `inset(${top * 100}% ${right * 100}% ${bottom * 100}% ${left * 100}%)`],
			);
			
			if (crop.left === left && crop.top === top && crop.right === right && crop.bottom === bottom) {
				return;
			}
			
			crop.left = left;
			crop.top = top;
			crop.right = right;
			crop.bottom = bottom;
			
			delete actions.reset.prior;
			
			background.handleViewChange();
			background.reset();
		};
		
		this.set = (crop) => {
			this.apply(crop);
			
			this.components.top.set({height: crop.top});
			this.components.right.set({width: crop.right});
			this.components.bottom.set({height: crop.bottom});
			this.components.left.set({width: crop.left});
		};
		
		this.onInactive = () => {
			this.apply(currentCrop);
			
			addListeners(this, false);
		};
		
		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);
				}
			};
		})();
	}(),
	
	configure: new function() {
		this.CODE = 'config';
		
		this.onActive = async () => {
			kill();
			
			await $config.edit();
			
			updateConfigs();
			
			viewport.focus();
			
			background.reset();
			
			ensureFramed();
			applyZoom();
		};
	}(),
	
	reset: new function() {
		this.CODE = 'reset';
		
		this.onActive = () => {
			if (this.prior) {
				zoom = this.prior.zoom;
				videoAngle = this.prior.videoAngle;
				Object.assign(midPoint, this.prior.midPoint);
				actions.crop.set(this.prior.crop);
				
				applyMidPoint();
				applyRotation();
				applyZoom();
				
				return;
			}
			
			const prior = {
				zoom,
				videoAngle,
				crop: {...crop},
				midPoint: {...midPoint},
			};
			
			reset();
			
			actions.crop.stop();
			background.reset();
			
			this.prior = prior;
		};
	}(),
};

const reset = () => {
	resetMidPoint();
	
	resetZoom();
	
	resetRotation();
};

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 = () => {
		stop();
		
		video.addEventListener('play', () => {
			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];
				cinematics.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 action of Object.values(actions)) {
		if ('CLASS_ABLE' in action) {
			css.tag(action.CLASS_ABLE, false);
		}
	}
	
	Enabler.setActive(false);
};

const stop = (immediate = false) => {
	kill();
	
	observer.stop?.(immediate);
	
	containers.background.remove();
	containers.foreground.remove();
	containers.tracker.remove();
	crosshair.container.remove();
	
	actions.crop.stop();
	background.stop();
	
	reset();
};

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();
};

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);
	cinematics = document.querySelector('#cinematics');
	
	// 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();
	
	if (!video.ended) {
		start();
	}
});

// 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);
		
		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();
});

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址