YouTube View Controls

Zoom, rotate & crop YouTube videos.

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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/1518577/%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(
	'YTVZ_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: [
								['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: '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: 'Button',
					get: (_, configs) => ({button: Object.assign(...configs)}),
					children: getHideable([
						{
							label: 'Peek On Hover?',
							value: false,
							get: ({value: peek}) => ({peek}),
						},
						{
							label: 'Show Background Indicator?',
							value: true,
							get: ({value: indicate}) => ({indicate}),
						},
					]),
				},
				{
					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 = 'ytvz-action-able';
	this.CLASS_DRAGGING = 'ytvz-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();
			}
			
			button.setIndicator();
		}
		
		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 = 'ytvz-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 = 'ytvz-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;
						
						button.setIndicator();
						
						bgCanvas.style.removeProperty('visibility');
						
						play();
						
						return;
					}
				}
				
				this.isHidden = true;
				
				button.setIndicator();
				
				bgCanvas.style.visibility = 'hidden';
				
				stopCopyLoop?.();
			};
		})();
		
		const loop = {};
		
		this.start = () => {
			const config = $config.get().background;
			
			if (!config.on || getCroppedWidth() === 0 || getCroppedHeight() === 0) {
				return;
			}
			
			if (!Enabler.isHidingBg) {
				container.style.removeProperty('display');
			}
			
			if (config.replace) {
				css.tag(CLASS_CINOMATICS);
			}
			
			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 = 'ytvz-crop-handle';
			static CLASS_EDGES = {
				left: 'ytvz-crop-left',
				top: 'ytvz-crop-top',
				right: 'ytvz-crop-right',
				bottom: 'ytvz-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 = 'ytvz-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 = 'ytvz-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 = 'ytvz-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 = 'ytvz-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 (event) => {
					if (!priorEvent) {
						priorEvent = event;
						
						return;
					}
					
					change.x = (event.offsetX - (priorEvent.offsetX + change.x)) * multipliers.pan;
					change.y = (event.offsetY - (priorEvent.offsetY - change.y)) * -multipliers.pan;
					
					midPoint.x += change.x / video.clientWidth;
					midPoint.y += change.y / video.clientHeight;
					
					ensureFramed();
					
					this.updateCrosshair();
					
					priorEvent = event;
				};
			};
			
			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 = 'ytvz-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 = 'ytvz-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 = () => {
		// delay stopping to reset observed elements
		window.setTimeout(() => {
			video.removeEventListener('ended', onVideoEnd);
			video.removeEventListener('resize', onResolutionChange);
			
			styleObserver.disconnect();
			videoObserver.disconnect();
			viewportObserver.disconnect();
		}, 0);
	};
}();

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 = () => {
	kill();
	
	observer.stop?.();
	
	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 = 6;
		const gap = 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 clip = document.createElementNS(ns, 'clipPath');
		
		clip.id = 'ytvzClip';
		
		const clipPath = document.createElementNS(ns, 'path');
		
		clipPath.style.transformOrigin = 'center';
		clipPath.style.transition = '0.5s d';
		
		clip.append(clipPath);
		
		const inner = document.createElementNS(ns, 'circle');
		
		inner.setAttribute('r', `${(radius - gap) * 2}`);
		inner.setAttribute('cx', `${center}`);
		inner.setAttribute('cy', `${center}`);
		inner.setAttribute('clip-path', `url(#${clip.id})`);
		
		const outer = document.createElementNS(ns, 'circle');
		
		outer.setAttribute('r', `${(radius - gap) * 2 + 2}`);
		outer.setAttribute('cx', `${center}`);
		outer.setAttribute('cy', `${center}`);
		outer.style.fill = 'none';
		
		const spikeContainer = document.createElementNS(ns, 'g');
		
		spikeContainer.style.strokeWidth = '0';
		spikeContainer.setAttribute('transform', `translate(${box / 2} ${box / 2}) rotate(-6.5)`);
		
		const spikes = [];
		
		for (let angle = 0; angle < 360; angle += 36) {
			const spike = document.createElementNS(ns, 'path');
			
			spike.style.rotate = `${angle}deg`;
			spike.style.transition = '0.5s d';
			
			spikes.push(spike);
		}
		
		const dSpike = (() => {
			const baseY = (radius - gap) * -2 - 2;
			const spikeY = baseY - 2.2;
			const spikeX = 1;
			
			return {
				on: `path("M${-spikeX} ${baseY}L${0} ${spikeY}L${spikeX} ${baseY}Z")`,
				off: `path("M${-spikeX} ${baseY}L${0} ${baseY}L${spikeX} ${baseY}Z")`,
			};
		})();
		
		spikeContainer.append(...spikes);
		
		this.setIndicator = () => {
			const {on, indicate} = $config.get().button;
			
			if (!on || !indicate) {
				return;
			}
			
			const d = dSpike[(!video.ended && isOn && !background.isHidden && !Enabler.isHidingBg) ? 'on' : 'off'];
			
			for (const spike of spikes) {
				spike.style.d = d;
			}
		};
		
		this.updateIndicator = () => {
			const {on, indicate} = $config.get().button;
			
			if (!on) {
				this.element.style.display = 'none';
			} else {
				this.element.style.removeProperty('display');
			}
			
			if (!indicate) {
				spikeContainer.style.display = 'none';
			} else {
				spikeContainer.style.removeProperty('display');
			}
		};
		
		$config.ready.then(() => {
			this.updateIndicator();
		});
		
		svg.append(clip, inner, outer, spikeContainer);
		
		this.element.append(svg);
		
		this.update = (() => {
			const getPath = (stroke, offset) => 'path("' +
				`M0 0L0 ${box}L${box} ${box}L${box} 0Z` +
				`M0 ${center - stroke / 2}l${center - stroke / 2 + offset / 2} 0l0 ${-center}l${stroke} 0l0 ${center}l${center} 0l0 ${stroke}l${-center - offset} 0l0 ${center}l${-stroke} 0l0 ${-center}l${-center} 0Z` +
				'")';
			
			const pathOn = getPath(3, 2);
			const pathOff = getPath(0, 0);
			
			return () => clipPath.style.d = isOn ? pathOn : pathOff;
		})();
	})();
	
	this.edit = async () => {
		await $config.edit();
		
		background.reset();
		this.updateIndicator();
		this.setIndicator();
		
		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 = 'YTVZ_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 = 'ytvz-peek';
	
	this.element.addEventListener('pointerover', () => {
		if (!$config.get().button.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().button.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();
	}
	
	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);
	
	containers.foreground.style.zIndex = crosshair.container.style.zIndex = video.parentElement.computedStyleMap().get('z-index').value;
	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();
});