YouTube Viewfinding

Zoom, rotate & crop YouTube videos

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        YouTube Viewfinding
// @version     0.13
// @description Zoom, rotate & crop YouTube videos
// @author      Callum Latham
// @namespace   https://greasyfork.org/users/696211-ctl2
// @license     GNU GPLv3
// @compatible  chrome
// @compatible  edge
// @compatible  firefox Video dimensions affect page scrolling
// @compatible  opera Video dimensions affect page scrolling
// @match       *://www.youtube.com/*
// @match       *://youtube.com/*
// @require     https://update.greasyfork.org/scripts/446506/1537901/%24Config.js
// @grant       GM.setValue
// @grant       GM.getValue
// @grant       GM.deleteValue
// ==/UserScript==

/* global $Config */

(() => {
// Don't run in non-embed frames (e.g. stream chat frame)
if (window.parent !== window && window.location.pathname.split('/')[1] !== 'embed') {
	return;
}

const VAR_ZOOM = '--viewfind-zoom';
const LIMITS = {none: 'None', static: 'Static', fit: 'Fit'};

const $config = new $Config(
	'VIEWFIND_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 = -1;
			
			return () => ++id;
		})();
		
		const glowHideId = getHideId();
		
		return {
			get: (_, configs) => Object.assign(...configs),
			children: [
				{
					label: 'Controls',
					children: [
						{
							label: 'Keybinds',
							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 seed = {
									value: '',
									listeners: {
										keydown: (event) => {
											switch (event.key) {
											case 'Enter':
											case 'Escape':
												return;
											}
											
											event.preventDefault();
											
											event.target.value = event.code;
											
											event.target.dispatchEvent(new InputEvent('input'));
										},
									},
								};
								
								const getKeys = (children) => new Set(children.map(({value}) => value));
								
								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', ['KeyZ'], 'pan'],
												['Rotate', ['IntlBackslash'], 'rotate'],
												['Crop', ['KeyZ', 'IntlBackslash'], 'crop'],
											].map(([label, keys, id]) => getNode(label, keys, ({children}) => ({id, keys: getKeys(children)}))),
										],
									},
									getNode('Reset', ['KeyX'], ({children}) => ({reset: {keys: getKeys(children)}})),
									getNode('Configure', ['AltLeft', 'KeyX'], ({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,
									// 150000 * (5 - 0.8) / 2π ≈ 100000
									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 = false]) => ({
								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: [
						...(() => {
							const typeNode = {
								label: 'Type',
								get: ({value}) => ({type: value}),
							};
							
							const staticNode = {
								label: 'Value (%)',
								predicate: (value) => value >= 0 || 'Limit must be positive',
								inputAttributes: {min: 0},
								get: ({value}) => ({custom: value / 100}),
							};
							
							const fitNode = {
								label: 'Glow Allowance (%)',
								predicate: (value) => value >= 0 || 'Allowance must be positive',
								inputAttributes: {min: 0},
								get: ({value}) => ({frame: value / 100}),
							};
							
							const options = Object.values(LIMITS);
							
							const getNode = (label, key, value, customValue, glowAllowance = 300) => {
								const staticId = getHideId();
								const fitId = getHideId();
								const onUpdate = (value) => ({
									hide: {
										[staticId]: value !== LIMITS.static,
										[fitId]: value !== LIMITS.fit,
									},
								});
								
								return {
									label,
									get: (_, configs) => ({[key]: Object.assign(...configs)}),
									children: [
										{...typeNode, value, options, onUpdate},
										{...staticNode, value: customValue, hideId: staticId},
										{...fitNode, value: glowAllowance, hideId: fitId},
									],
								};
							};
							
							return [
								getNode('Zoom In Limit', 'zoomInLimit', LIMITS.static, 500, 0),
								getNode('Zoom Out Limit', 'zoomOutLimit', LIMITS.static, 80),
								getNode('Pan Limit', 'panLimit', LIMITS.static, 50),
								{
									label: 'Snap Pan Limit',
									get: (_, configs) => ({snapPanLimit: Object.assign(...configs)}),
									children: ((hideId) => [
										{
											...typeNode,
											value: LIMITS.fit,
											options: [LIMITS.none, LIMITS.fit],
											onUpdate: (value) => ({hide: {[hideId]: value !== LIMITS.fit}}),
										},
										{...fitNode, value: 0, hideId},
									])(getHideId()),
								},
							];
						})(),
						{
							label: 'While Viewfinding',
							get: (_, configs) => {
								const {overlayKill, overlayHide, ...config} = Object.assign(...configs);
								
								return {
									active: {
										overlayRule: overlayKill && [overlayHide ? 'display' : 'pointer-events', 'none'],
										...config,
									},
								};
							},
							children: [
								{
									label: 'Pause Video?',
									value: false,
									get: ({value: pause}) => ({pause}),
								},
								{
									label: 'Hide Glow?',
									value: false,
									get: ({value: hideGlow}) => ({hideGlow}),
									hideId: glowHideId,
								},
								...((hideId) => [
									{
										label: 'Disable Overlay?',
										value: true,
										get: ({value: overlayKill}, configs) => Object.assign({overlayKill}, ...configs),
										onUpdate: (value) => ({hide: {[hideId]: !value}}),
										children: [
											{
												label: 'Hide Overlay?',
												value: false,
												get: ({value: overlayHide}) => ({overlayHide}),
												hideId,
											},
										],
									},
								])(getHideId()),
							],
						},
						
					],
				},
				{
					label: 'Glow',
					value: true,
					onUpdate: (value) => ({hide: {[glowHideId]: !value}}),
					get: ({value: on}, configs) => {
						if (!on) {
							return {};
						}
						
						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 {
								glow: {
									...config,
									sampleCount: config.size,
									interval: 1000 / fps,
									fps,
								},
							};
						}
						
						return {
							glow: {
								...config,
								interval: 1000 / config.fps,
								sampleCount,
							},
						};
					},
					children: [
						(() => {
							const [seed, getChild] = (() => {
								const options = ['blur', 'brightness', 'contrast', 'drop-shadow', 'grayscale', 'hue-rotate', 'invert', 'opacity', 'saturate', 'sepia'];
								const ids = {};
								const hide = {};
								
								for (const option of options) {
									ids[option] = getHideId();
									
									hide[ids[option]] = true;
								}
								
								const min0Amount = {
									label: 'Amount (%)',
									value: 100,
									predicate: (value) => value >= 0 || 'Amount must be positive',
									inputAttributes: {min: 0},
								};
								
								const max100Amount = {
									label: 'Amount (%)',
									value: 0,
									predicate: (value) => {
										if (value < 0) {
											return 'Amount must be positive';
										}
										
										return value <= 100 || 'Amount may not exceed 100%';
									},
									inputAttributes: {min: 0, max: 100},
								};
								
								const getScaled = (value) => `calc(${value}px/var(${VAR_ZOOM}))`;
								
								const root = {
									label: 'Function',
									options,
									value: options[0],
									get: ({value}, configs) => {
										const config = Object.assign(...configs);
										
										switch (value) {
										case options[0]:
											return {
												filter: config.blurScale ? `blur(${config.blur}px)` : `blur(${getScaled(config.blur)})`,
												blur: {
													x: config.blur,
													y: config.blur,
													scale: config.blurScale,
												},
											};
										
										case options[3]:
											return {
												filter: config.shadowScale ?
													`drop-shadow(${config.shadow} ${config.shadowX}px ${config.shadowY}px ${config.shadowSpread}px)` :
													`drop-shadow(${config.shadow} ${getScaled(config.shadowX)} ${getScaled(config.shadowY)} ${getScaled(config.shadowSpread)})`,
												blur: {
													x: config.shadowSpread + Math.abs(config.shadowX),
													y: config.shadowSpread + Math.abs(config.shadowY),
													scale: config.shadowScale,
												},
											};
										
										case options[5]:
											return {filter: `hue-rotate(${config.hueRotate}deg)`};
										}
										
										return {filter: `${value}(${config[value]}%)`};
									},
									onUpdate: (value) => ({hide: {...hide, [ids[value]]: false}}),
								};
								
								const children = {
									'blur': [
										{
											label: 'Distance (px)',
											value: 0,
											get: ({value}) => ({blur: value}),
											predicate: (value) => value >= 0 || 'Distance must be positive',
											inputAttributes: {min: 0},
											hideId: ids.blur,
										},
										{
											label: 'Scale?',
											value: false,
											get: ({value}) => ({blurScale: value}),
											hideId: ids.blur,
										},
									],
									'brightness': [
										{
											...min0Amount,
											hideId: ids.brightness,
											get: ({value}) => ({brightness: value}),
										},
									],
									'contrast': [
										{
											...min0Amount,
											hideId: ids.contrast,
											get: ({value}) => ({contrast: value}),
										},
									],
									'drop-shadow': [
										{
											label: 'Colour',
											input: 'color',
											value: '#FFFFFF',
											get: ({value}) => ({shadow: value}),
											hideId: ids['drop-shadow'],
										},
										{
											label: 'Horizontal Offset (px)',
											value: 0,
											get: ({value}) => ({shadowX: value}),
											hideId: ids['drop-shadow'],
										},
										{
											label: 'Vertical Offset (px)',
											value: 0,
											get: ({value}) => ({shadowY: value}),
											hideId: ids['drop-shadow'],
										},
										{
											label: 'Spread (px)',
											value: 0,
											predicate: (value) => value >= 0 || 'Spread must be positive',
											inputAttributes: {min: 0},
											get: ({value}) => ({shadowSpread: value}),
											hideId: ids['drop-shadow'],
										},
										{
											label: 'Scale?',
											value: true,
											get: ({value}) => ({shadowScale: value}),
											hideId: ids['drop-shadow'],
										},
									],
									'grayscale': [
										{
											...max100Amount,
											hideId: ids.grayscale,
											get: ({value}) => ({grayscale: value}),
										},
									],
									'hue-rotate': [
										{
											label: 'Angle (deg)',
											value: 0,
											get: ({value}) => ({hueRotate: value}),
											hideId: ids['hue-rotate'],
										},
									],
									'invert': [
										{
											...max100Amount,
											hideId: ids.invert,
											get: ({value}) => ({invert: value}),
										},
									],
									'opacity': [
										{
											...max100Amount,
											value: 100,
											hideId: ids.opacity,
											get: ({value}) => ({opacity: value}),
										},
									],
									'saturate': [
										{
											...min0Amount,
											hideId: ids.saturate,
											get: ({value}) => ({saturate: value}),
										},
									],
									'sepia': [
										{
											...max100Amount,
											hideId: ids.sepia,
											get: ({value}) => ({sepia: value}),
										},
									],
								};
								
								return [
									{...root, children: Object.values(children).flat()}, (id, ...values) => {
										const replacements = [];
										
										for (const [i, child] of children[id].entries()) {
											replacements.push({...child, value: values[i]});
										}
										
										return {
											...root,
											value: id,
											children: Object.values({...children, [id]: replacements}).flat(),
										};
									},
								];
							})();
							
							return {
								label: 'Filter',
								get: (_, configs) => {
									const scaled = {x: 0, y: 0};
									const unscaled = {x: 0, y: 0};
									
									let filter = '';
									
									for (const config of configs) {
										filter += config.filter;
										
										if ('blur' in config) {
											const target = config.blur.scale ? scaled : unscaled;
											
											target.x = Math.max(target.x, config.blur.x);
											target.y = Math.max(target.y, config.blur.y);
										}
									}
									
									return {filter, blur: {scaled, unscaled}};
								},
								children: [
									getChild('saturate', 150),
									getChild('brightness', 150),
									getChild('blur', 25, false),
								],
								seed,
							};
						})(),
						{
							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: doFlip}) => ({doFlip}),
								},
							],
						},
						{
							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}),
						},
					].map((node) => ({...node, hideId: glowHideId})),
				},
				{
					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: value / 100}),
								},
							],
						},
						{
							label: 'Crosshair',
							get: (value, configs) => ({crosshair: Object.assign(...configs)}),
							children: [
								{
									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}),
								},
								((hideId) => ({
									label: 'Text',
									value: true,
									onUpdate: (value) => ({hide: {[hideId]: !value}}),
									get: ({value}, configs) => {
										if (!value) {
											return {};
										}
										
										const {translateX, translateY, ...config} = Object.assign(...configs);
										
										return {
											text: {
												translate: {
													x: translateX,
													y: translateY,
												},
												...config,
											},
										};
									},
									children: [
										{
											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}),
										},
									].map((node) => ({...node, hideId})),
								}))(getHideId()),
								{
									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 CLASS_VIEWFINDER = 'viewfind-element';
const PI_HALVES = [Math.PI / 2, Math.PI, 3 * Math.PI / 2, Math.PI * 2];
const SELECTOR_VIDEO = '#movie_player video.html5-main-video';

// STATE

let video;
let altTarget;
let viewport;
let cinematics;

let stopped = true;
let stopDrag;

const viewportAngles = new function () {
	this.set = () => {
		this.side = getTheta(0, 0, viewport.clientWidth, viewport.clientHeight);
		
		// equals `getTheta(0, 0, viewport.clientHeight, viewport.clientWidth)`
		this.base = PI_HALVES[0] - this.side;
		
		glow.handleViewChange(true);
	};
}();

// ROTATION HELPERS

const getTheta = (fromX, fromY, toX, toY) => Math.atan2(toY - fromY, toX - fromX);

const getRotatedCorners = (x, y) => {
	const angle = rotation.value - PI_HALVES[0];
	const radius = Math.sqrt(x * x + y * y);
	
	const topAngle = getTheta(0, 0, x, y) + angle;
	const bottomAngle = getTheta(0, 0, x, -y) + angle;
	
	return [
		{
			x: Math.abs(radius * Math.cos(topAngle)),
			y: Math.abs(radius * Math.sin(topAngle)),
		},
		{
			x: Math.abs(radius * Math.cos(bottomAngle)),
			y: Math.abs(radius * Math.sin(bottomAngle)),
		},
	];
};

// CSS HELPER

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

// ACTION MANAGER

const enabler = new function () {
	this.CLASS_ABLE = 'viewfind-action-able';
	this.CLASS_DRAGGING = 'viewfind-action-dragging';
	
	this.keys = new Set();
	
	this.didPause = false;
	this.isHidingGlow = false;
	
	this.setActive = (action) => {
		const {active, keys} = $config.get();
		
		if (active.hideGlow && Boolean(action) !== this.isHidingGlow) {
			if (action) {
				this.isHidingGlow = true;
				
				glow.hide();
			} else if (this.isHidingGlow) {
				this.isHidingGlow = false;
				
				glow.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 (stopped || stopDrag || video.ended) {
			return;
		}
		
		const {keys} = $config.get();
		
		let activeAction;
		
		for (const action of Object.values(actions)) {
			if (
				!this.keys.isSupersetOf(keys[action.CODE].keys) || activeAction && ('toggle' in keys[action.CODE] ?
					!('toggle' in keys[activeAction.CODE]) || keys[activeAction.CODE].keys.size >= keys[action.CODE].keys.size :
					!('toggle' in keys[activeAction.CODE]) && keys[activeAction.CODE].keys.size >= 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;
		}
		
		if (activeAction === this.activeAction) {
			return;
		}
		
		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.stop = () => {
		css.tag(this.CLASS_ABLE, false);
		
		for (const action of Object.values(actions)) {
			if ('CLASS_ABLE' in action) {
				css.tag(action.CLASS_ABLE, false);
			}
		}
		
		this.setActive(false);
	};
	
	this.updateConfig = (() => {
		const rule = new css.Toggleable();
		const selector = `${css.getSelector(this.CLASS_ABLE)} #contentContainer.tp-yt-app-drawer[swipe-open]::after`
			+ `,${css.getSelector(this.CLASS_ABLE)} #movie_player > .html5-video-container ~ :not(.${CLASS_VIEWFINDER})`;
		
		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)} #movie_player`, ['cursor', 'grabbing']);
	css.add(`${css.getSelector(this.CLASS_ABLE)} #movie_player`, ['cursor', 'grab']);
}();

// ELEMENT CONTAINER SETUP

const containers = new function () {
	for (const name of ['background', 'foreground', 'tracker']) {
		this[name] = document.createElement('div');
		
		this[name].classList.add(CLASS_VIEWFINDER);
	}
	
	// make an outline of the uncropped video
	css.add(`${css.getSelector(enabler.CLASS_ABLE)} #${this.foreground.id = 'viewfind-outlined'}`, ['outline', '1px solid white']);
	
	this.background.style.position = this.foreground.style.position = 'absolute';
	this.background.style.pointerEvents = this.foreground.style.pointerEvents = this.tracker.style.pointerEvents = 'none';
	this.tracker.style.height = this.tracker.style.width = '100%';
}();

// MODIFIERS

class Cache {
	targets = [];
	
	constructor(...targets) {
		for (const source of targets) {
			this.targets.push({source});
		}
	}
	
	update(target) {
		return target.value !== (target.value = target.source.value);
	}
	
	isStale() {
		return this.targets.reduce((value, target) => value || this.update(target), false);
	}
}

class ConfigCache extends Cache {
	static id = 0;
	
	id = this.constructor.id;
	
	constructor(...targets) {
		super(...targets);
	}
	
	isStale() {
		if (this.id === (this.id = this.constructor.id)) {
			return super.isStale();
		}
		
		for (const target of this.targets) {
			target.value = target.source.value;
		}
		
		return true;
	}
}

const zoom = new function () {
	this.value = 1;
	
	const scaleRule = new css.Toggleable();
	
	this.reset = () => {
		this.value = 1;
		
		video.style.removeProperty('scale');
		
		scaleRule.remove();
		scaleRule.add(':root', [VAR_ZOOM, '1']);
	};
	
	this.apply = () => {
		video.style.setProperty('scale', `${this.value}`);
		
		scaleRule.remove();
		scaleRule.add(':root', [VAR_ZOOM, `${this.value}`]);
		
		delete actions.reset.restore;
	};
	
	this.getFit = (width = 1, height = 1) => {
		const [corner0, corner1] = getRotatedCorners(width * video.clientWidth, height * video.clientHeight);
		
		return 1 / Math.max(
			corner0.x / viewport.clientWidth, corner1.x / viewport.clientWidth,
			corner0.y / viewport.clientHeight, corner1.y / viewport.clientHeight,
		);
	};
	
	this.constrain = (() => {
		const limitGetters = {
			[LIMITS.static]: ({custom}) => custom,
			[LIMITS.fit]: ({frame}, glow) => {
				if (glow) {
					const base = glow.end - 1;
					const {scaled, unscaled} = glow.blur;
					
					return this.getFit(
						1 + Math.max(0, base + Math.max(unscaled.x / video.clientWidth, scaled.x * this.value / video.clientWidth)) * frame,
						1 + Math.max(0, base + Math.max(unscaled.y / video.clientHeight, scaled.y * this.value / video.clientHeight)) * frame,
					);
				}
				
				return this.getFit();
			},
		};
		
		return () => {
			const {zoomOutLimit, zoomInLimit, glow} = $config.get();
			
			if (zoomOutLimit.type !== 'None') {
				this.value = Math.max(limitGetters[zoomOutLimit.type](zoomOutLimit, glow), this.value);
			}
			
			if (zoomInLimit.type !== 'None') {
				this.value = Math.min(limitGetters[zoomInLimit.type](zoomInLimit, glow), this.value);
			}
			
			this.apply();
		};
	})();
}();

const rotation = new function () {
	this.value = PI_HALVES[0];
	
	this.reset = () => {
		this.value = PI_HALVES[0];
		
		video.style.removeProperty('rotate');
	};
	
	this.apply = () => {
		// Conversion from anticlockwise rotation from the x-axis to clockwise rotation from the y-axis
		video.style.setProperty('rotate', `${PI_HALVES[0] - this.value}rad`);
		
		delete actions.reset.restore;
	};
	
	// dissimilar from other constrain functions in that no effective limit is applied
	// -1.5π < rotation <= 0.5π
	// 0 <= 0.5π - rotation < 2π
	this.constrain = () => {
		this.value %= PI_HALVES[3];
		
		if (this.value > PI_HALVES[0]) {
			this.value -= PI_HALVES[3];
		} else if (this.value <= -PI_HALVES[2]) {
			this.value += PI_HALVES[3];
		}
		
		this.apply();
	};
}();

const position = new function () {
	this.x = this.y = 0;
	
	this.getValues = () => ({x: this.x, y: this.y});
	
	this.reset = () => {
		this.x = this.y = 0;
		
		video.style.removeProperty('translate');
	};
	
	this.apply = () => {
		video.style.setProperty('transform-origin', `${(0.5 + this.x) * 100}% ${(0.5 - this.y) * 100}%`);
		video.style.setProperty('translate', `${-this.x * 100}% ${this.y * 100}%`);
		
		delete actions.reset.restore;
	};
	
	this.constrain = (() => {
		const applyFrameValues = (lowCorner, highCorner, sub, main) => {
			this[sub] = Math.max(-lowCorner[sub], Math.min(highCorner[sub], this[sub]));
			
			const progress = (this[sub] + lowCorner[sub]) / (highCorner[sub] + lowCorner[sub]);
			
			if (this[main] < 0) {
				const bound = Number.isNaN(progress) ?
					-lowCorner[main] :
					(lowCorner[main] - highCorner[main]) * progress - lowCorner[main];
				
				this[main] = Math.max(this[main], bound);
			} else {
				const bound = Number.isNaN(progress) ?
					lowCorner[main] :
					(highCorner[main] - lowCorner[main]) * progress + lowCorner[main];
				
				this[main] = Math.min(this[main], bound);
			}
		};
		
		const applyFrame = (firstCorner, secondCorner, firstCornerAngle, secondCornerAngle) => {
			// The anti-clockwise angle from the first (top left) corner
			const midPointAngle = (getTheta(0, 0, this.x, this.y) + PI_HALVES[1] + firstCornerAngle) % PI_HALVES[3];
			
			if (midPointAngle % PI_HALVES[1] < secondCornerAngle) {
				// Frame is x-bound
				const [lowCorner, highCorner] = this.x >= 0 ? [firstCorner, secondCorner] : [secondCorner, firstCorner];
				
				applyFrameValues(lowCorner, highCorner, 'y', 'x');
			} else {
				// Frame is y-bound
				const [lowCorner, highCorner] = this.y >= 0 ? [firstCorner, secondCorner] : [secondCorner, firstCorner];
				
				applyFrameValues(lowCorner, highCorner, 'x', 'y');
			}
		};
		
		const getBoundApplyFrame = (() => {
			const getCorner = (first, second) => {
				if (zoom.value < first.z) {
					return {x: 0, y: 0};
				}
				
				if (zoom.value < second.z) {
					const progress = (1 / zoom.value - 1 / first.z) / (1 / second.z - 1 / first.z);
					
					return {
						x: Math.max(0, progress * (second.x - first.x) + first.x),
						y: Math.max(0, progress * (second.y - first.y) + first.y),
					};
				}
				
				return {
					x: Math.max(0, 0.5 - (0.5 - second.x) / (zoom.value / second.z)),
					y: Math.max(0, 0.5 - (0.5 - second.y) / (zoom.value / 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);
				
				for (const [same, different] of [['x', 'y'], ['y', 'x']]) {
					if (fFirstCorner[same] === 0 && fSecondCorner[same] === 0) {
						if (fFirstCorner[different] > fSecondCorner[different]) {
							return applyFrame.bind(null, fFirstCorner, fFirstCorner, fFirstCornerAngle, fFirstCornerAngle);
						}
						
						return applyFrame.bind(null, fSecondCorner, fSecondCorner, fSecondCornerAngle, fSecondCornerAngle);
					}
				}
				
				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 = ([[g, e], [f, d]], [[k, i], [j, h]], doFlip) => {
				const x = Math.abs(position.x);
				const y = Math.abs(position.y);
				
				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.value = 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.value = 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 = position.x >= 0 === position.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(position.x), Math.abs(position.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(position.x), Math.abs(position.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.value = pair1[0];
			};
		})();
		
		const getZoomPoints = (mod) => {
			const [videoWidth, videoHeight] = (() => {
				const {glow} = $config.get();
				
				if (glow) {
					const {scaled, unscaled} = glow.blur;
					
					return [
						(video.clientWidth + Math.max(0, glow.end * video.clientWidth - video.clientWidth + Math.max(unscaled.x, scaled.x * zoom.value)) * mod) / 2,
						(video.clientHeight + Math.max(0, glow.end * video.clientHeight - video.clientHeight + Math.max(unscaled.y, scaled.y * zoom.value)) * mod) / 2,
					];
				}
				
				return [video.clientWidth / 2, video.clientHeight / 2];
			})();
			
			const viewportWidth = viewport.clientWidth / 2;
			const viewportHeight = viewport.clientHeight / 2;
			
			const quadrant = Math.floor(rotation.value / PI_HALVES[0]) + 3;
			
			const yAngle = (() => {
				const angle = (rotation.value + PI_HALVES[3]) % PI_HALVES[0];
				
				return quadrant % 2 === 0 ? angle : PI_HALVES[0] - angle;
			})();
			
			const progress = yAngle / PI_HALVES[0] * -2 + 1;
			
			const [cornerAVars, cornerBVars] = [
				[
					(() => {
						const angleA = Math.atan(progress * viewportWidth / viewportHeight);
						const angleB = angleA + yAngle;
						
						const h = Math.abs(videoHeight / Math.cos(angleB));
						
						const z = viewportHeight / (Math.cos(angleA) * h);
						const x = (videoWidth - videoHeight * Math.tan(angleB)) / video.clientWidth;
						
						return {
							x,
							y: 0,
							z,
						};
					})(),
					(() => {
						const angleA = Math.atan(progress * viewportWidth / viewportHeight);
						const angleB = PI_HALVES[0] - angleA - yAngle;
						
						const h = Math.abs(videoWidth / Math.cos(angleB));
						
						const z = viewportHeight / (Math.cos(angleA) * h);
						const y = (videoHeight - videoWidth * Math.tan(angleB)) / video.clientHeight;
						
						return {
							x: 0,
							y,
							z,
						};
					})(),
				],
				[
					(() => {
						const angleA = Math.atan(progress * viewportHeight / viewportWidth);
						const angleB = angleA + yAngle;
						
						const h = Math.abs(videoWidth / Math.cos(angleB));
						
						const z = viewportWidth / (Math.cos(angleA) * h);
						const y = (videoHeight - videoWidth * Math.tan(angleB)) / video.clientHeight;
						
						return {
							x: 0,
							y,
							z,
						};
					})(),
					(() => {
						const angleA = Math.atan(progress * viewportHeight / viewportWidth);
						const angleB = PI_HALVES[0] - angleA - yAngle;
						
						const h = Math.abs(videoHeight / Math.cos(angleB));
						
						const z = viewportWidth / (Math.cos(angleA) * h);
						const x = (videoWidth - videoHeight * Math.tan(angleB)) / video.clientWidth;
						
						return {
							x,
							y: 0,
							z,
						};
					})(),
				],
			].map(([xCorner, yCorner]) => xCorner.z < yCorner.z ? [xCorner, yCorner] : [yCorner, xCorner]);
			
			return quadrant % 2 === 1 ? [...cornerAVars, ...cornerBVars] : [...cornerBVars, ...cornerAVars];
		};
		
		const handlers = {
			[LIMITS.static]: ({custom: ratio}) => {
				const bound = 0.5 + (ratio - 0.5) / zoom.value;
				
				position.x = Math.max(-bound, Math.min(bound, position.x));
				position.y = Math.max(-bound, Math.min(bound, position.y));
			},
			[LIMITS.fit]: (() => {
				const cache = new ConfigCache(rotation, zoom);
				
				let boundApplyFrame;
				
				return ({frame}) => {
					if (cache.isStale()) {
						boundApplyFrame = getBoundApplyFrame(...getZoomPoints(frame));
					}
					
					boundApplyFrame();
				};
			})(),
		};
		
		const snapHandlers = {
			[LIMITS.fit]: (() => {
				const cache = new ConfigCache(rotation, zoom);
				
				let boundSnapZoom;
				
				return ({frame}) => {
					if (cache.isStale()) {
						boundSnapZoom = snapZoom.bind(null, ...getZoomPoints(frame));
					}
					
					boundSnapZoom();
					
					zoom.constrain();
				};
			})(),
		};
		
		return (doZoom = false) => {
			const {panLimit, snapPanLimit} = $config.get();
			
			if (doZoom) {
				snapHandlers[snapPanLimit.type]?.(snapPanLimit);
			}
			
			handlers[panLimit.type]?.(panLimit);
			
			this.apply();
		};
	})();
}();

const crop = new function () {
	this.top = this.right = this.bottom = this.left = 0;
	
	this.getValues = () => ({top: this.top, right: this.right, bottom: this.bottom, left: this.left});
	
	this.reveal = () => {
		this.top = this.right = this.bottom = this.left = 0;
		
		rule.remove();
	};
	
	this.reset = () => {
		this.reveal();
		
		actions.crop.reset();
	};
	
	const rule = new css.Toggleable();
	
	this.apply = () => {
		rule.remove();
		rule.add(
			`${SELECTOR_VIDEO}:not(.${this.CLASS_ABLE} *)`,
			['clip-path', `inset(${this.top * 100}% ${this.right * 100}% ${this.bottom * 100}% ${this.left * 100}%)`],
		);
		
		delete actions.reset.restore;
		
		glow.handleViewChange();
		glow.reset();
	};
	
	this.getDimensions = (width = video.clientWidth, height = video.clientHeight) => [
		width * (1 - this.left - this.right),
		height * (1 - this.top - this.bottom),
	];
}();

// FUNCTIONALITY

const glow = (() => {
	const videoCanvas = new OffscreenCanvas(0, 0);
	const videoCtx = videoCanvas.getContext('2d', {alpha: false});
	
	const glowCanvas = document.createElement('canvas');
	const glowCtx = glowCanvas.getContext('2d', {alpha: false});
	
	glowCanvas.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 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);
			
			if (dy === 0) {
				this.hasCorners = false;
				
				return;
			}
			
			this.hasCorners = true;
			
			const giveCorner0 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, 1, dx, 0, dWidth, dy);
			const giveCorner1 = glowCtx.drawImage.bind(glowCtx, 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 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);
			
			if (dx === 0) {
				this.hasCorners = false;
				
				return;
			}
			
			this.hasCorners = true;
			
			const giveCorner0 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, 1, sHeight, 0, dy, dx, dHeight);
			const giveCorner1 = glowCtx.drawImage.bind(glowCtx, 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) {
			glowCtx.save();
			
			glowCtx.clip(this.clipPath);
			
			super.update(doFill);
			
			glowCtx.restore();
		}
	}
	
	const components = {
		left: new Side(),
		right: new Side(),
		top: new Base(),
		bottom: new Base(),
	};
	
	const setComponentDimensions = (sampleCount, size, isInset, doFlip) => {
		const [croppedWidth, croppedHeight] = crop.getDimensions();
		const halfCanvas = {x: Math.ceil(glowCanvas.width / 2), y: Math.ceil(glowCanvas.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 * glowCanvas.width, videoCanvas.height / croppedHeight * glowCanvas.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, glowCanvas.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], [glowCanvas.width, 0]]);
		
		components.bottom.setDimensions(doFlip, videoCanvas.width, sampleCount, 0, videoCanvas.height - 1, dWidthScale, glowCanvas.height - dHeight, sideWidth, dHeight);
		components.bottom.setClipPath([[0, glowCanvas.height], [halfCanvas.x, halfCanvas.y], [glowCanvas.width, glowCanvas.height]]);
	};
	
	class Instance {
		constructor() {
			const {filter, sampleCount, size, end, doFlip} = $config.get().glow;
			
			// Setup canvases
			
			glowCanvas.style.setProperty('filter', filter);
			
			[glowCanvas.width, glowCanvas.height] = crop.getDimensions().map((dimension) => dimension * end);
			
			glowCanvas.style.setProperty('left', `${crop.left * 100 + (1 - end) * (1 - crop.left - crop.right) * 50}%`);
			glowCanvas.style.setProperty('top', `${crop.top * 100 + (1 - end) * (1 - crop.top - crop.bottom) * 50}%`);
			
			[videoCanvas.width, videoCanvas.height] = crop.getDimensions(video.videoWidth, 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(glowCanvas);
		containers.background.appendChild(container);
		
		this.isHidden = false;
		
		let instance, startCopyLoop, stopCopyLoop;
		
		const play = () => {
			if (!video.paused && !this.isHidden && !enabler.isHidingGlow) {
				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 glow isn't visible
		this.handleViewChange = (() => {
			const cache = new Cache(rotation, zoom);
			
			let corners;
			
			return (doForce = false) => {
				if (doForce || cache.isStale()) {
					corners = getRotatedCorners(viewport.clientWidth / 2 / zoom.value, viewport.clientHeight / 2 / zoom.value);
				}
				
				const videoX = position.x * video.clientWidth;
				const videoY = position.y * video.clientHeight;
				
				for (const corner of corners) {
					if (
						// unpause if the viewport extends more than 1 pixel beyond a video edge
						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;
						
						glowCanvas.style.removeProperty('visibility');
						
						play();
						
						return;
					}
				}
				
				this.isHidden = true;
				
				glowCanvas.style.visibility = 'hidden';
				
				stopCopyLoop?.();
			};
		})();
		
		const loop = {};
		
		this.start = () => {
			const config = $config.get().glow;
			
			if (!config) {
				return;
			}
			
			if (!enabler.isHidingGlow) {
				container.style.removeProperty('display');
			}
			
			// todo handle this?
			if (crop.left + crop.right >= 1 || crop.top + crop.bottom >= 1) {
				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}] Glow 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 peek = (stop = false) => {
	const prior = {
		zoom: zoom.value,
		rotation: rotation.value,
		crop: crop.getValues(),
		position: position.getValues(),
	};
	
	position.reset();
	rotation.reset();
	zoom.reset();
	crop.reset();
	
	glow[stop ? 'stop' : 'reset']();
	
	return () => {
		zoom.value = prior.zoom;
		rotation.value = prior.rotation;
		Object.assign(position, prior.position);
		Object.assign(crop, prior.crop);
		
		actions.crop.set(prior.crop);
		
		position.apply();
		rotation.apply();
		zoom.apply();
		crop.apply();
	};
};

const actions = (() => {
	const drag = (event, clickCallback, moveCallback, target = video) => new Promise((resolve) => {
		event.stopImmediatePropagation();
		event.preventDefault();
		
		// window blur events don't fire if devtools is open
		stopDrag?.();
		
		target.setPointerCapture(event.pointerId);
		
		css.tag(enabler.CLASS_DRAGGING);
		
		const cancel = (event) => {
			event.stopImmediatePropagation();
			event.preventDefault();
		};
		
		document.addEventListener('click', cancel, true);
		document.addEventListener('dblclick', cancel, true);
		
		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);
			}
		};
		
		if (clickCallback) {
			target.addEventListener('pointermove', clickDisallowListener);
			target.addEventListener('pointerup', clickCallback, {once: true});
		}
		
		target.addEventListener('pointermove', moveCallback);
		
		stopDrag = () => {
			css.tag(enabler.CLASS_DRAGGING, false);
			
			target.removeEventListener('pointermove', moveCallback);
			
			if (clickCallback) {
				target.removeEventListener('pointermove', clickDisallowListener);
				target.removeEventListener('pointerup', clickCallback);
			}
			
			// delay removing listeners for events that happen after pointerup
			window.setTimeout(() => {
				document.removeEventListener('dblclick', cancel, true);
				document.removeEventListener('click', cancel, 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 getOnScroll = (() => {
		// https://stackoverflow.com/a/30134826
		const multipliers = [1, 40, 800];
		
		return (callback) => (event) => {
			event.stopImmediatePropagation();
			event.preventDefault();
			
			if (event.deltaY !== 0) {
				callback(event.deltaY * multipliers[event.deltaMode]);
			}
		};
	})();
	
	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);
	};
	
	return {
		crop: new function () {
			let top = 0, right = 0, bottom = 0, left = 0, handle;
			
			const values = {};
			
			Object.defineProperty(values, 'top', {get: () => top, set: (value) => top = value});
			Object.defineProperty(values, 'right', {get: () => right, set: (value) => right = value});
			Object.defineProperty(values, 'bottom', {get: () => bottom, set: (value) => bottom = value});
			Object.defineProperty(values, 'left', {get: () => left, set: (value) => left = value});
			
			class Button {
				// allowance for rounding errors
				static ALLOWANCE_HANDLE = 0.0001;
				
				static CLASS_HANDLE = 'viewfind-crop-handle';
				static CLASS_EDGES = {
					left: 'viewfind-crop-left',
					top: 'viewfind-crop-top',
					right: 'viewfind-crop-right',
					bottom: 'viewfind-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 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);
						};
						
						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,
								});
							};
						};
						
						return async (event) => {
							if (event.buttons === 1) {
								const target = this.element.parentElement;
								
								if (this.isHandle) {
									this.setPanel();
								}
								
								await drag(event, clickListener, getDragListener(event, target), target);
								
								this.updateCounterpart();
							}
						};
					})());
				}
				
				notify() {
					for (const callback of this.callbacks) {
						callback();
					}
				}
				
				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) {
						values[edge] = 0;
					}
				}
			}
			
			class EdgeButton extends Button {
				constructor(edge) {
					super(edge);
					
					this.edge = edge;
				}
				
				updateCounterpart() {
					if (this.counterpart.isHandle) {
						this.counterpart.setHandle();
					}
				}
				
				setCrop(value = 0) {
					values[this.edge] = value;
				}
				
				setPanel() {
					this.isHandle = false;
					
					this.setCrop(handle);
					
					this.setHandle();
				}
			}
			
			class SideButton extends EdgeButton {
				flow() {
					let size = 1;
					
					if (top <= Button.ALLOWANCE_HANDLE) {
						size -= handle;
						
						this.element.style.top = `${handle * 100}%`;
					} else {
						size -= top;
						
						this.element.style.top = `${top * 100}%`;
					}
					
					if (bottom <= Button.ALLOWANCE_HANDLE) {
						size -= handle;
					} else {
						size -= bottom;
					}
					
					this.element.style.height = `${Math.max(0, size * 100)}%`;
				}
				
				setBounds(counterpart, components) {
					this.counterpart = components[counterpart];
					
					components.top.callbacks.push(() => {
						this.flow();
					});
					
					components.bottom.callbacks.push(() => {
						this.flow();
					});
				}
				
				setHandle(doNotify = true) {
					this.element.style.width = `${Math.min(1 - values[this.counterpart.edge], handle) * 100}%`;
					
					if (doNotify) {
						this.notify();
					}
				}
				
				set({width}, doUpdateCounterpart = true) {
					if (this.isHandle !== (this.isHandle = width <= Button.ALLOWANCE_HANDLE)) {
						this.flow();
					}
					
					if (doUpdateCounterpart) {
						this.updateCounterpart();
					}
					
					if (this.isHandle) {
						this.setCrop();
						
						this.setHandle();
						
						return;
					}
					
					const size = Math.min(1 - values[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 * 100}%`;
						this.element.style.height = `${(0.5 - handle) * 200}%`;
						this.element.style.width = `${handle * 100}%`;
						
						return;
					}
					
					this.flow();
					
					this.setHandle();
					
					this.updateCounterpart();
				}
			}
			
			class BaseButton extends EdgeButton {
				flow() {
					let size = 1;
					
					if (left <= Button.ALLOWANCE_HANDLE) {
						size -= handle;
						
						this.element.style.left = `${handle * 100}%`;
					} else {
						size -= left;
						
						this.element.style.left = `${left * 100}%`;
					}
					
					if (right <= Button.ALLOWANCE_HANDLE) {
						size -= handle;
					} else {
						size -= right;
					}
					
					this.element.style.width = `${Math.max(0, size) * 100}%`;
				}
				
				setBounds(counterpart, components) {
					this.counterpart = components[counterpart];
					
					components.left.callbacks.push(() => {
						this.flow();
					});
					
					components.right.callbacks.push(() => {
						this.flow();
					});
				}
				
				setHandle(doNotify = true) {
					this.element.style.height = `${Math.min(1 - values[this.counterpart.edge], handle) * 100}%`;
					
					if (doNotify) {
						this.notify();
					}
				}
				
				set({height}, doUpdateCounterpart = false) {
					if (this.isHandle !== (this.isHandle = height <= Button.ALLOWANCE_HANDLE)) {
						this.flow();
					}
					
					if (doUpdateCounterpart) {
						this.updateCounterpart();
					}
					
					if (this.isHandle) {
						this.setCrop();
						
						this.setHandle();
						
						return;
					}
					
					const size = Math.min(1 - values[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 * 100}%`;
						this.element.style.width = `${(0.5 - handle) * 200}%`;
						this.element.style.height = `${handle * 100}%`;
						
						return;
					}
					
					this.flow();
					
					this.setHandle();
					
					this.updateCounterpart();
				}
			}
			
			class CornerButton extends Button {
				static CLASS_NAME = 'viewfind-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 - values[this.sectors[0].counterpart.edge], handle) * 100}%`;
					} else {
						this.element.style.width = `${values[this.edges[0]] * 100}%`;
						
						isHandle = false;
					}
					
					if (this.sectors[1].isHandle) {
						this.element.style.height = `${Math.min(1 - values[this.sectors[1].counterpart.edge], handle) * 100}%`;
					} else {
						this.element.style.height = `${values[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 * 100}%`;
					this.element.style.height = `${handle * 100}%`;
					
					if (isGeneral) {
						return;
					}
					
					for (const sector of this.sectors) {
						sector.reset(false);
					}
				}
				
				setPanel() {
					for (const sector of this.sectors) {
						sector.setPanel();
					}
				}
			}
			
			this.CODE = 'crop';
			
			this.CLASS_ABLE = 'viewfind-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.reset = () => {
				for (const component of Object.values(this.components)) {
					component.reset(true);
				}
			};
			
			this.onRightClick = (event) => {
				if (event.target.parentElement.id === container.id) {
					return;
				}
				
				event.stopPropagation();
				event.preventDefault();
				
				if (stopDrag) {
					return;
				}
				
				this.reset();
			};
			
			this.onScroll = getOnScroll((distance) => {
				const increment = distance * $config.get().speeds.crop / zoom.value;
				
				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 multiplier = $config.get().multipliers.crop;
					
					const setX = ((right, left, change) => {
						const clamped = Math.max(-left, Math.min(right, change * multiplier / video.clientWidth));
						
						this.components.left.set({width: left + clamped});
						this.components.right.set({width: right - clamped});
					}).bind(undefined, right, left);
					
					const setY = ((top, bottom, change) => {
						const clamped = Math.max(-top, Math.min(bottom, change * multiplier / video.clientHeight));
						
						this.components.top.set({height: top + clamped});
						
						this.components.bottom.set({height: bottom - clamped});
					}).bind(undefined, top, bottom);
					
					let priorEvent;
					
					return ({offsetX, offsetY}) => {
						if (!priorEvent) {
							priorEvent = {offsetX, offsetY};
							
							return;
						}
						
						setX(offsetX - priorEvent.offsetX);
						setY(offsetY - priorEvent.offsetY);
					};
				};
				
				const clickListener = () => {
					zoom.value = zoom.getFit(1 - left - right, 1 - top - bottom);
					
					zoom.constrain();
					
					position.x = (left - right) / 2;
					position.y = (bottom - top) / 2;
					
					position.constrain();
				};
				
				return (event) => {
					if (event.buttons === 1) {
						drag(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));
			
			this.set = ({top, right, bottom, left}) => {
				this.components.top.set({height: top});
				this.components.right.set({width: right});
				this.components.bottom.set({height: bottom});
				this.components.left.set({width: left});
			};
			
			this.onInactive = () => {
				addListeners(this, false);
				
				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;
				
				crop.apply();
			};
			
			this.onActive = () => {
				const config = $config.get().crop;
				
				handle = config.handle / Math.max(zoom.value, 1);
				
				for (const component of [this.components.top, this.components.bottom, this.components.left, this.components.right]) {
					if (component.isHandle) {
						component.setHandle();
					}
				}
				
				crop.reveal();
				
				addListeners(this);
				
				if (!enabler.isHidingGlow) {
					glow.handleViewChange();
					
					glow.reset();
				}
			};
			
			const draggingSelector = css.getSelector(enabler.CLASS_DRAGGING);
			
			this.updateConfig = (() => {
				const rule = new css.Toggleable();
				
				return () => {
					// 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 = 'viewfind-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']);
				
				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'],
					);
					
					// in fullscreen, 16:9 videos get an offsetLeft of 1px on my 16:9 monitor
					// I'm extending buttons by 1px so that they reach the edge of screens like mine at default zoom
					css.add(`#${id}>.${sideClass}`, [`margin-${side}`, '-1px'], [`padding-${side}`, '1px']);
				}
				
				css.add(`#${id}:not(.${this.CLASS_ABLE} *)`, ['display', 'none']);
			})();
		}(),
		
		pan: new function () {
			this.CODE = 'pan';
			
			this.CLASS_ABLE = 'viewfind-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.value)}×\n${getSigned(position.x)}%\n${getSigned(position.y)}%`;
				};
			})();
			
			this.onScroll = getOnScroll((distance) => {
				const increment = distance * $config.get().speeds.zoom;
				
				if (increment > 0) {
					zoom.value *= 1 + increment;
				} else {
					zoom.value /= 1 - increment;
				}
				
				zoom.constrain();
				
				position.constrain();
				
				this.updateCrosshair();
			});
			
			this.onRightClick = (event) => {
				event.stopImmediatePropagation();
				event.preventDefault();
				
				if (stopDrag) {
					return;
				}
				
				position.x = position.y = 0;
				zoom.value = 1;
				
				position.apply();
				
				zoom.constrain();
				
				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 = (priorEvent.offsetX + change.x - offsetX) * multipliers.pan;
							change.y = (priorEvent.offsetY - change.y - offsetY) * -multipliers.pan;
							
							position.x += change.x / video.clientWidth;
							position.y += change.y / video.clientHeight;
							
							position.constrain();
							
							this.updateCrosshair();
						}
						
						// events in firefox seem to lose their data after finishing propagation
						// so assigning the whole event doesn't work
						priorEvent = {offsetX, offsetY};
					};
				};
				
				const clickListener = (event) => {
					position.x = event.offsetX / video.clientWidth - 0.5;
					// Y increases moving down the page
					// I flip that to make trigonometry easier
					position.y = -event.offsetY / video.clientHeight + 0.5;
					
					position.constrain(true);
					
					this.updateCrosshair();
				};
				
				return (event) => {
					if (event.buttons === 1) {
						drag(event, clickListener, getDragListener());
					}
				};
			})();
		}(),
		
		rotate: new function () {
			this.CODE = 'rotate';
			
			this.CLASS_ABLE = 'viewfind-action-able-rotate';
			
			this.onActive = () => {
				this.updateCrosshair();
				
				addListeners(this);
			};
			
			this.onInactive = () => {
				addListeners(this, false);
			};
			
			this.updateCrosshair = () => {
				const angle = PI_HALVES[0] - rotation.value;
				
				crosshair.text.innerText = `${Math.floor((PI_HALVES[0] - rotation.value) / Math.PI * 180)}°\n≈${Math.round(angle / PI_HALVES[0]) % 4 * 90}°`;
			};
			
			this.onScroll = getOnScroll((distance) => {
				rotation.value += distance * $config.get().speeds.rotate;
				
				rotation.constrain();
				
				zoom.constrain();
				position.constrain();
				
				this.updateCrosshair();
			});
			
			this.onRightClick = (event) => {
				event.stopImmediatePropagation();
				event.preventDefault();
				
				if (stopDrag) {
					return;
				}
				
				rotation.value = PI_HALVES[0];
				
				rotation.apply();
				
				zoom.constrain();
				position.constrain();
				
				this.updateCrosshair();
			};
			
			this.onMouseDown = (() => {
				const getDragListener = () => {
					const {multipliers} = $config.get();
					const middleX = containers.tracker.clientWidth / 2;
					const middleY = containers.tracker.clientHeight / 2;
					
					const priorPosition = position.getValues();
					const priorZoom = zoom.value;
					
					let priorMouseTheta;
					
					return (event) => {
						const mouseTheta = getTheta(middleX, middleY, event.offsetX, event.offsetY);
						
						if (priorMouseTheta === undefined) {
							priorMouseTheta = mouseTheta;
							
							return;
						}
						
						position.x = priorPosition.x;
						position.y = priorPosition.y;
						zoom.value = priorZoom;
						
						rotation.value += (priorMouseTheta - mouseTheta) * multipliers.rotate;
						
						rotation.constrain();
						
						zoom.constrain();
						position.constrain();
						
						this.updateCrosshair();
						
						priorMouseTheta = mouseTheta;
					};
				};
				
				const clickListener = () => {
					rotation.value = Math.round(rotation.value / PI_HALVES[0]) * PI_HALVES[0];
					
					rotation.constrain();
					
					zoom.constrain();
					position.constrain();
					
					this.updateCrosshair();
				};
				
				return (event) => {
					if (event.buttons === 1) {
						drag(event, clickListener, getDragListener(), containers.tracker);
					}
				};
			})();
		}(),
		
		configure: new function () {
			this.CODE = 'config';
			
			this.onActive = async () => {
				await $config.edit();
				
				updateConfigs();
				
				viewport.focus();
				
				glow.reset();
				
				position.constrain();
				zoom.constrain();
			};
		}(),
		
		reset: new function () {
			this.CODE = 'reset';
			
			this.onActive = () => {
				if (this.restore) {
					this.restore();
				} else {
					this.restore = peek();
				}
			};
		}(),
	};
})();

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 = 'viewfind-crosshair';
	
	this.container.id = id;
	this.container.classList.add(CLASS_VIEWFINDER);
	
	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.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.lines.horizontal.style.backgroundColor = this.lines.vertical.style.backgroundColor = colour.fill;
		
		if (text) {
			this.text.style.color = 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;
			
			this.container.append(this.text);
		} else {
			this.text.remove();
		}
		
		if (doClip) {
			this.clip();
		}
	};
	
	$config.ready.then(() => {
		this.updateConfig(false);
	});
}();

// ELEMENT CHANGE LISTENERS

const observer = new function () {
	const onResolutionChange = () => {
		glow.handleSizeChange?.();
	};
	
	const styleObserver = new MutationObserver((() => {
		const properties = ['top', 'left', 'width', 'height', 'scale', 'rotate', 'translate', 'transform-origin'];
		
		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 doesn't exist for embedded vids
				if (cinematics) {
					cinematics.style[property] = video.style[property];
				}
			}
			
			glow.handleViewChange();
		};
	})());
	
	const videoObserver = new ResizeObserver(() => {
		viewportAngles.set();
		
		glow.handleSizeChange?.();
	});
	
	const viewportObserver = new ResizeObserver(() => {
		viewportAngles.set();
		
		crosshair.clip();
	});
	
	this.start = () => {
		video.addEventListener('resize', onResolutionChange);
		
		styleObserver.observe(video, {attributes: true, attributeFilter: ['style']});
		viewportObserver.observe(viewport);
		videoObserver.observe(video);
		
		glow.handleViewChange();
	};
	
	this.stop = () => {
		video.removeEventListener('resize', onResolutionChange);
		
		styleObserver.disconnect();
		viewportObserver.disconnect();
		videoObserver.disconnect();
	};
}();

// NAVIGATION LISTENERS

const stop = () => {
	if (stopped) {
		return;
	}
	
	stopped = true;
	
	enabler.stop();
	
	stopDrag?.();
	
	observer.stop();
	
	containers.background.remove();
	containers.foreground.remove();
	containers.tracker.remove();
	crosshair.container.remove();
	
	return peek(true);
};

const start = () => {
	if (!stopped || viewport.classList.contains('ad-showing')) {
		return;
	}
	
	stopped = false;
	
	observer.start();
	
	glow.start();
	
	viewport.append(containers.background, containers.foreground, containers.tracker, crosshair.container);
	
	// User may have a static minimum zoom greater than 1
	zoom.constrain();
	
	enabler.handleChange();
};

const updateConfigs = () => {
	ConfigCache.id++;
	
	enabler.updateConfig();
	actions.crop.updateConfig();
	crosshair.updateConfig();
};

// LISTENER ASSIGNMENTS

// load & navigation
(() => {
	const getNode = (node, selector, ...selectors) => {
		for (const child of node.children) {
			if (child.matches(selector)) {
				return selectors.length === 0 ? child : getNode(child, ...selectors);
			}
		}
		
		return null;
	};
	
	const init = async () => {
		if (unsafeWindow.ytplayer?.bootstrapPlayerContainer?.childElementCount > 0) {
			// wait for the video to be moved to ytd-app
			await new Promise((resolve) => {
				new MutationObserver((changes, observer) => {
					resolve();
					
					observer.disconnect();
				}).observe(unsafeWindow.ytplayer.bootstrapPlayerContainer, {childList: true});
			});
		}
		
		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();
		}
		
		const pageManager = getNode(document.body, 'ytd-app', '#content', 'ytd-page-manager');
		
		if (pageManager) {
			const page = pageManager.getCurrentPage() ?? await new Promise((resolve) => {
				new MutationObserver(([{addedNodes: [page]}], observer) => {
					if (page) {
						resolve(page);
						
						observer.disconnect();
					}
				}).observe(pageManager, {childList: true});
			});
			
			await page.playerEl.getPlayerPromise();
			
			video = page.playerEl.querySelector(SELECTOR_VIDEO);
			cinematics = page.querySelector('#cinematics');
			
			// navigation to a new video
			new MutationObserver(() => {
				video.removeEventListener('play', startIfReady);
				
				power.off();
				
				// this callback can occur after metadata loads
				startIfReady();
			}).observe(page, {attributes: true, attributeFilter: ['video-id']});
			
			// navigation to a non-video page
			new MutationObserver(() => {
				if (video.src === '') {
					video.removeEventListener('play', startIfReady);
					
					power.off();
				}
			}).observe(video, {attributes: true, attributeFilter: ['src']});
		} else {
			video = document.body.querySelector(SELECTOR_VIDEO);
		}
		
		viewport = video.parentElement.parentElement;
		altTarget = viewport.parentElement;
		
		containers.foreground.style.zIndex = crosshair.container.style.zIndex = video.parentElement.computedStyleMap?.().get('z-index').value ?? 10;
		crosshair.clip();
		viewportAngles.set();
		
		const startIfReady = () => {
			if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
				start();
			}
		};
		
		const power = new function () {
			this.off = () => {
				delete this.wake;
				
				stop();
			};
			
			this.sleep = () => {
				this.wake ??= stop();
			};
		}();
		
		new MutationObserver((() => {
			return () => {
				// video end
				if (viewport.classList.contains('ended-mode')) {
					power.off();
					
					video.addEventListener('play', startIfReady, {once: true});
				// ad start
				} else if (viewport.classList.contains('ad-showing')) {
					power.sleep();
				}
			};
		})()).observe(viewport, {attributes: true, attributeFilter: ['class']});
		
		// glow initialisation requires video dimensions
		startIfReady();
		
		video.addEventListener('loadedmetadata', () => {
			if (viewport.classList.contains('ad-showing')) {
				return;
			}
			
			start();
			
			if (power.wake) {
				power.wake();
				
				delete power.wake;
			}
		});
	};
	
	if (!('ytPageType' in unsafeWindow) || unsafeWindow.ytPageType === 'watch') {
		init();
		
		return;
	}
	
	const initListener = ({detail: {newPageType}}) => {
		if (newPageType === 'ytd-watch-flexy') {
			init();
			
			document.body.removeEventListener('yt-page-type-changed', initListener);
		}
	};
	
	document.body.addEventListener('yt-page-type-changed', initListener);
})();

// keyboard state change

document.addEventListener('keydown', ({code}) => {
	if (enabler.toggled) {
		enabler.keys[enabler.keys.has(code) ? 'delete' : 'add'](code);
		
		enabler.handleChange();
	} else if (!enabler.keys.has(code)) {
		enabler.keys.add(code);
		
		enabler.handleChange();
	}
});

document.addEventListener('keyup', ({code}) => {
	if (enabler.toggled) {
		return;
	}
	
	if (enabler.keys.has(code)) {
		enabler.keys.delete(code);
		
		enabler.handleChange();
	}
});

window.addEventListener('blur', () => {
	if (enabler.toggled) {
		stopDrag?.();
	} else {
		enabler.keys.clear();
		
		enabler.handleChange();
	}
});
})();