YouTube Viewfinding

Zoom, rotate & crop YouTube videos

// ==UserScript==
// @name        YouTube Viewfinding
// @version     0.20
// @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/1588535/%24Config.js
// @grant       GM.setValue
// @grant       GM.getValue
// @grant       GM.deleteValue
// ==/UserScript==

/* global $Config */

(() => {
const isEmbed = window.location.pathname.split('/')[1] === 'embed';

// Don't run in non-embed frames (e.g. stream chat frame)
if (window.parent !== window && !isEmbed) {
	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: ([actions, reset, configure]) => {
								const keybinds = [...actions.children.slice(1), reset, configure].map(({children}) => children.filter(({value}) => value !== '').map(({value}) => value));
								
								for (let i = 0; i < keybinds.length - 1; ++i) {
									for (let j = i + 1; j < keybinds.length; ++j) {
										if (keybinds[i].length === keybinds[j].length && keybinds[i].every((keyA) => keybinds[j].some((keyB) => keyA === keyB))) {
											return 'Another action has this keybind';
										}
									}
								}
								
								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.filter(({value}) => value !== '').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 hiddenNodes = {
								[LIMITS.static]: {
									label: 'Value (%)',
									predicate: (value) => value >= 0 || 'Limit must be positive',
									inputAttributes: {min: 0},
									get: ({value}) => ({custom: value / 100}),
								},
								[LIMITS.fit]: {
									label: 'Glow Allowance (%)',
									predicate: (value) => value >= 0 || 'Allowance must be positive',
									inputAttributes: {min: 0},
									get: ({value}) => ({frame: value / 100}),
								},
							};
							
							const getNode = (label, key, value, options, ...hidden) => {
								const hideIds = {};
								const children = [{...typeNode, value, options}];
								
								for (const {id, value} of hidden) {
									const node = {...hiddenNodes[id], value, hideId: getHideId()};
									
									hideIds[node.hideId] = id;
									
									children.push(node);
								}
								
								if (hidden.length > 0) {
									children[0].onUpdate = (value) => {
										const hide = {};
										
										for (const [id, type] of Object.entries(hideIds)) {
											hide[id] = value !== type;
										}
										
										return {hide};
									};
								}
								
								return {
									label,
									get: (_, configs) => ({[key]: Object.assign(...configs)}),
									children,
								};
							};
							
							return [
								getNode(
									'Zoom In Limit',
									'zoomInLimit',
									LIMITS.static,
									[LIMITS.none, LIMITS.static, LIMITS.fit],
									{id: LIMITS.static, value: 500},
									{id: LIMITS.fit, value: 0},
								),
								getNode(
									'Zoom Out Limit',
									'zoomOutLimit',
									LIMITS.static,
									[LIMITS.none, LIMITS.static, LIMITS.fit],
									{id: LIMITS.static, value: 80},
									{id: LIMITS.fit, value: 300},
								),
								getNode(
									'Pan Limit',
									'panLimit',
									LIMITS.static,
									[LIMITS.none, LIMITS.static, LIMITS.fit],
									{id: LIMITS.static, value: 50},
								),
								getNode(
									'Snap Pan Limit',
									'snapPanLimit',
									LIMITS.fit,
									[LIMITS.none, LIMITS.fit],
								),
							];
						})(),
						{
							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}),
										},
									],
								},
							],
						},
					],
				},
			],
		};
	})(),
	{
		defaultStyle: {
			headBase: '#c80000',
			headButtonExit: '#000000',
			borderHead: '#ffffff',
			borderTooltip: '#c80000',
			width: Math.min(90, screen.width / 16),
			height: 90,
		},
		outerStyle: {
			zIndex: 10000,
			scrollbarColor: 'initial',
		},
		patches: [
			// removing "Glow Allowance" from pan limits
			({children: [, {children}]}) => {
				// pan
				children[2].children.splice(2, 1);
				// snap pan
				children[3].children.splice(1, 1);
			},
		],
	},
);

const CLASS_VIEWFINDER = 'viewfind-element';
const DEGREES = {
	45: Math.PI / 4,
	90: Math.PI / 2,
	180: Math.PI,
	270: Math.PI / 2 * 3,
	360: Math.PI * 2,
};
const SELECTOR_VIDEO = '#movie_player video.html5-main-video';

// STATE

// elements
let video;
let altTarget;
let viewport;
let cinematics;

// derived values
let videoTheta;
let videoHypotenuse;
let isThin;
let viewportRatio;
let viewportRatioInverse;
const halfDimensions = {
	video: {},
	viewport: {},
};

// other
let stopped = true;
let stopDrag;

const handleVideoChange = () => {
	DimensionCache.id++;
	
	halfDimensions.video.width = video.clientWidth / 2;
	halfDimensions.video.height = video.clientHeight / 2;
	
	videoTheta = getTheta(0, 0, video.clientWidth, video.clientHeight);
	videoHypotenuse = Math.sqrt(halfDimensions.video.width * halfDimensions.video.width + halfDimensions.video.height * halfDimensions.video.height);
};

const handleViewportChange = () => {
	DimensionCache.id++;
	
	isThin = getTheta(0, 0, viewport.clientWidth, viewport.clientHeight) < videoTheta;
	
	halfDimensions.viewport.width = viewport.clientWidth / 2;
	halfDimensions.viewport.height = viewport.clientHeight / 2;
	
	viewportRatio = viewport.clientWidth / viewport.clientHeight;
	viewportRatioInverse = 1 / viewportRatio;
	
	position.constrain();
	
	glow.handleViewChange(true);
};

// ROTATION HELPERS

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

const getRotatedCorners = (radius, theta) => {
	const angle0 = DEGREES[90] - theta + rotation.value;
	const angle1 = theta + rotation.value - DEGREES[90];
	
	return [
		{
			x: Math.abs(radius * Math.cos(angle0)),
			y: Math.abs(radius * Math.sin(angle0)),
		},
		{
			x: Math.abs(radius * Math.cos(angle1)),
			y: Math.abs(radius * Math.sin(angle1)),
		},
	];
};

// 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 (
				keys[action.CODE].keys.size === 0 || !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;
	}
}

class DimensionCache extends ConfigCache {
	static id = 0;
}

const rotation = new function () {
	this.value = DEGREES[90];
	
	this.reset = () => {
		this.value = DEGREES[90];
		
		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', `${DEGREES[90] - 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 %= DEGREES[360];
		
		if (this.value > DEGREES[90]) {
			this.value -= DEGREES[360];
		} else if (this.value <= -DEGREES[270]) {
			this.value += DEGREES[360];
		}
		
		this.apply();
	};
}();

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;
	};
	
	const getFit = (corner0, corner1, doSplit = false) => {
		const x = Math.max(corner0.x, corner1.x) / viewport.clientWidth;
		const y = Math.max(corner0.y, corner1.y) / viewport.clientHeight;
		
		return doSplit ? [0.5 / x, 0.5 / y] : 0.5 / Math.max(x, y);
	};
	
	this.getFit = (width, height) => getFit(...getRotatedCorners(Math.sqrt(width * width + height * height), getTheta(0, 0, width, height)));
	this.getVideoFit = (doSplit) => getFit(...getRotatedCorners(videoHypotenuse, videoTheta), doSplit);
	
	this.constrain = (() => {
		const limitGetters = {
			[LIMITS.static]: [({custom}) => custom, ({custom}) => custom],
			[LIMITS.fit]: (() => {
				const getGetter = () => {
					const zoomCache = new Cache(this);
					const rotationCache = new DimensionCache(rotation);
					const configCache = new ConfigCache();
					
					let updateOnZoom;
					
					let value;
					
					return ({frame}, glow) => {
						let fallthrough = rotationCache.isStale();
						
						if (configCache.isStale()) {
							if (glow) {
								const {scaled} = glow.blur;
								
								updateOnZoom = frame > 0 && (scaled.x > 0 || scaled.y > 0);
							} else {
								updateOnZoom = false;
							}
							
							fallthrough = true;
						}
						
						if (zoomCache.isStale() && updateOnZoom || fallthrough) {
							if (glow) {
								const base = glow.end - 1;
								const {scaled, unscaled} = glow.blur;
								
								value = this.getFit(
									halfDimensions.video.width + Math.max(0, base * halfDimensions.video.width + Math.max(unscaled.x, scaled.x * this.value)) * frame,
									halfDimensions.video.height + Math.max(0, base * halfDimensions.video.height + Math.max(unscaled.y, scaled.y * this.value)) * frame,
								);
							} else {
								value = this.getVideoFit();
							}
						}
						
						return value;
					};
				};
				
				return [getGetter(), getGetter()];
			})(),
		};
		
		return () => {
			const {zoomOutLimit, zoomInLimit, glow} = $config.get();
			
			if (zoomOutLimit.type !== 'None') {
				this.value = Math.max(limitGetters[zoomOutLimit.type][0](zoomOutLimit, glow), this.value);
			}
			
			if (zoomInLimit.type !== 'None') {
				this.value = Math.min(limitGetters[zoomInLimit.type][1](zoomInLimit, glow, 1), this.value);
			}
			
			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 = (() => {
		// logarithmic progress from "low" to infinity
		const getProgress = (low, target) => 1 - low / target;
		
		const getProgressed = ({x: fromX, y: fromY, z: lowZ}, {x: toX, y: toY}, targetZ) => {
			const p = getProgress(lowZ, targetZ);
			
			return {x: p * (toX - fromX) + fromX, y: p * (toY - fromY) + fromY};
		};
		
		// y = mx + c
		const getLineY = ({m, c}, x = this.x) => m * x + c;
		// x = (y - c) / m
		const getLineX = ({m, c}, y = this.y) => (y - c) / m;
		
		const getM = (from, to) => (to.y - from.y) / (to.x - from.x);
		const getLine = (m, {x, y}) => ({c: y - m * x, m});
		const getFlipped = ({x, y}) => ({x: -x, y: -y});
		
		const correctY = (line, left, right) => {
			if (this.x >= left.x && this.x <= right.x) {
				this.y = getLineY(line, this.x);
				
				return true;
			}
		};
		
		const correctX = (line, bottom, top) => {
			if (this.y >= bottom.y && this.y <= top.y) {
				this.x = getLineX(line, this.y);
				
				return true;
			}
		};
		
		const isAbove = ({m, c}, {x, y} = this) => m * x + c < y;
		const isRight = ({m, c}, {x, y} = this) => (y - c) / m < x;
		
		const apply2DFrame = (points, lines) => {
			const {x, y} = this;
			
			if (Math.abs(lines.right.c) === Infinity) {
				this.x = Math.min(points.topRight.x, Math.max(points.topLeft.x, this.x));
			} else if (isRight(lines.right)) {
				if (correctX(lines.right, points.bottomRight, points.topRight)) {
					return;
				}
			} else if (!isRight(lines.left)) {
				if (correctX(lines.left, points.bottomLeft, points.topLeft)) {
					return;
				}
			}
			
			if (isAbove(lines.top)) {
				if (correctY(lines.top, points.topLeft, points.topRight)) {
					return;
				}
			} else if (!isAbove(lines.bottom)) {
				if (correctY(lines.bottom, points.bottomLeft, points.bottomRight)) {
					return;
				}
			}
			
			if (x <= points.bottomLeft.x && y <= points.bottomLeft.y) {
				this.x = points.bottomLeft.x;
				this.y = points.bottomLeft.y;
			} else if (x >= points.bottomRight.x && y <= points.bottomRight.y) {
				this.x = points.bottomRight.x;
				this.y = points.bottomRight.y;
			} else if (x <= points.topLeft.x && y >= points.topLeft.y) {
				this.x = points.topLeft.x;
				this.y = points.topLeft.y;
			} else if (x >= points.topRight.x && y >= points.topRight.y) {
				this.x = points.topRight.x;
				this.y = points.topRight.y;
			}
		};
		
		const apply1DSideFrame = {
			x: (line) => {
				this.x = Math.max(-line.x, Math.min(line.x, this.x));
				
				this.y = getLineY(line);
			},
			y: (line) => {
				this.y = Math.max(-line.y, Math.min(line.y, this.y));
				
				this.x = getLineX(line);
			},
		};
		
		const swap = (array, i0, i1) => {
			const temp = array[i0];
			
			array[i0] = array[i1];
			array[i1] = temp;
		};
		
		const getBoundApplyFrame = (() => {
			const getBound = (first, second, isTopLeft) => {
				if (zoom.value <= first.z) {
					return false;
				}
				
				if (zoom.value >= second.z) {
					const progress = zoom.value / second.z;
					
					const x = isTopLeft ?
						-0.5 - (-0.5 - second.x) / progress :
						0.5 - (0.5 - second.x) / progress;
					
					return {
						x,
						y: 0.5 - (0.5 - second.y) / progress,
					};
				}
				
				return {
					...getProgressed(first, second.vpEnd, zoom.value),
					axis: second.vpEnd.axis,
					m: second.y / second.x,
					c: 0,
				};
			};
			
			const getFrame = (point0, point1) => {
				const points = {};
				const lines = {};
				
				const flipped0 = getFlipped(point0);
				const flipped1 = getFlipped(point1);
				
				const m0 = getM(point0, point1);
				const m1 = getM(flipped0, point1);
				
				lines.top = getLine(m0, point0);
				lines.bottom = getLine(m0, flipped0);
				
				lines.left = getLine(m1, point0);
				lines.right = getLine(m1, flipped0);
				
				points.topLeft = point0;
				points.topRight = point1;
				points.bottomLeft = flipped1;
				points.bottomRight = flipped0;
				
				if (video.clientWidth < video.clientHeight) {
					if (getLineX(lines.right, 0) < getLineX(lines.left, 0)) {
						swap(lines, 'right', 'left');
						
						swap(points, 'bottomLeft', 'bottomRight');
						swap(points, 'topLeft', 'topRight');
					}
				} else {
					if (lines.top.c < lines.bottom.c) {
						swap(lines, 'top', 'bottom');
						
						swap(points, 'topLeft', 'bottomLeft');
						swap(points, 'topRight', 'bottomRight');
					}
				}
				
				return [points, lines];
			};
			
			return (first0, second0, first1, second1) => {
				const point0 = getBound(first0, second0, true);
				const point1 = getBound(first1, second1, false);
				
				if (!point0 && !point1) {
					return () => {
						this.x = this.y = 0;
					};
				}
				
				if (!point0 || !point1 || point0.axis && point0.axis === point1.axis) {
					// todo choose the longer line?
					const point = point0 || point1;
					const {axis} = point;
					
					point.axis ??= Math.abs(point.x) > Math.abs(point.y) ? 'x' : 'y';
					
					if (point[point.axis] < 0) {
						point.x = -point.x;
						point.y = -point.y;
					}
					
					if (!axis) {
						point.m = point.y / point.x;
						
						point.c = 0;
					}
					
					return apply1DSideFrame[point.axis].bind(null, point);
				}
				
				return apply2DFrame.bind(null, ...getFrame(point0, point1));
			};
		})();
		
		const snapZoom = (() => {
			const getDirected = (first, second, flipX, flipY) => {
				const line0 = [first, {}];
				const line1 = [{z: second.z}, {}];
				
				if (flipX) {
					line0[1].x = -second.vpEnd.x;
					line1[0].x = -second.x;
					line1[1].x = -0.5;
				} else {
					line0[1].x = second.vpEnd.x;
					line1[0].x = second.x;
					line1[1].x = 0.5;
				}
				
				if (flipY) {
					line0[1].y = -second.vpEnd.y;
					line1[0].y = -second.y;
					line1[1].y = -0.5;
				} else {
					line0[1].y = second.vpEnd.y;
					line1[0].y = second.y;
					line1[1].y = 0.5;
				}
				
				return [line0, line1];
			};
			
			// https://math.stackexchange.com/questions/2223691/intersect-2-lines-at-the-same-ratio-through-a-point
			const getIntersectProgress = ({x, y}, [{x: g, y: e}, {x: f, y: d}], [{x: k, y: i}, {x: j, y: h}], doFlip) => {
				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 getLineFromPoints = (from, to) => getLine(getM(from, to), from);
			
			// line with progressed start point
			const getProgressedLine = (line, {z}) => [getProgressed(...line, z), line[1]];
			
			return (first0, _second0, first1, second1) => {
				const second0 = {..._second0, x: -_second0.x, vpEnd: {..._second0.vpEnd, x: -_second0.vpEnd.x}};
				
				const absPosition = {x: Math.abs(this.x), y: Math.abs(this.y)};
				
				const getPairings = (flipX0, flipY0, flipX1, flipY1) => {
					const [lineFirst0, lineSecond0] = getDirected(first0, second0, flipX0, flipY0);
					const [lineFirst1, lineSecond1] = getDirected(first1, second1, flipX1, flipY1);
					
					// array structure is:
					// start zoom for both lines
					// 0 line start and its infinite zoom point
					// 1 line start and its infinite zoom point
					
					return [
						first0.z >= first1.z ?
							[first0.z, lineFirst0, getProgressedLine(lineFirst1, first0)] :
							[first1.z, getProgressedLine(lineFirst0, first1), lineFirst1],
						
						...second0.z >= second1.z ?
							[
								[second1.z, getProgressedLine(lineFirst0, second1), lineSecond1],
								[second0.z, lineSecond0, getProgressedLine(lineSecond1, second0)],
							] :
							[
								[second0.z, lineSecond0, getProgressedLine(lineFirst1, second0)],
								[second1.z, getProgressedLine(lineSecond0, second1), lineSecond1],
							],
					];
				};
				
				const [pair0, pair1, pair2, doFlip = false] = (() => {
					if (this.x >= 0 !== this.y >= 0) {
						return isAbove(getLineFromPoints(second0, {x: 0.5, y: 0.5}), absPosition) ?
							[...getPairings(false, false, true, false), true] :
							getPairings(false, false, false, true);
					}
					
					return isAbove(getLineFromPoints(second1, {x: 0.5, y: 0.5}), absPosition) ?
						getPairings(true, false, false, false) :
						[...getPairings(false, true, false, false), true];
				})();
				
				const applyZoomPairSecond = ([z, ...pair], maxP = 1) => {
					const p = getIntersectProgress(absPosition, ...pair, doFlip);
					
					if (p >= 0 && p <= maxP) {
						// I don't think the >= 1 check is necessary but best be safe
						zoom.value = p >= 1 ? Number.MAX_SAFE_INTEGER : z / (1 - p);
						
						return true;
					}
					
					return false;
				};
				
				if (
					applyZoomPairSecond(pair2)
					|| applyZoomPairSecond(pair1, getProgress(pair1[0], pair2[0]))
					|| applyZoomPairSecond(pair0, getProgress(pair0[0], pair1[0]))
				) {
					return;
				}
				
				zoom.value = pair0[0];
			};
		})();
		
		const getZoomPoints = (() => {
			const getPoints = (fitZoom, doFlip) => {
				const getGenericRotated = (x, y, angle) => {
					const radius = Math.sqrt(x * x + y * y);
					const pointTheta = getTheta(0, 0, x, y) + angle;
					
					return {
						x: radius * Math.cos(pointTheta),
						y: radius * Math.sin(pointTheta),
					};
				};
				
				const getRotated = (xRaw, yRaw) => {
					// Multiplying by video dimensions to have the axes' scales match the video's
					// Using midPoint's raw values would only work if points moved elliptically around the centre of rotation
					const rotated = getGenericRotated(xRaw * video.clientWidth, yRaw * video.clientHeight, (DEGREES[90] - rotation.value) % DEGREES[180]);
					
					rotated.x /= video.clientWidth;
					rotated.y /= video.clientHeight;
					
					return rotated;
				};
				
				return [
					{...getRotated(halfDimensions.viewport.width / video.clientWidth / fitZoom[0], 0), axis: doFlip ? 'y' : 'x'},
					{...getRotated(0, halfDimensions.viewport.height / video.clientHeight / fitZoom[1]), axis: doFlip ? 'x' : 'y'},
				];
			};
			
			const getIntersection = (line, corner, middle) => {
				const getIntersection = (line0, line1) => {
					const a0 = line0[0].y - line0[1].y;
					const b0 = line0[1].x - line0[0].x;
					const c0 = line0[1].x * line0[0].y - line0[0].x * line0[1].y;
					
					const a1 = line1[0].y - line1[1].y;
					const b1 = line1[1].x - line1[0].x;
					const c1 = line1[1].x * line1[0].y - line1[0].x * line1[1].y;
					
					const d = a0 * b1 - b0 * a1;
					
					return {
						x: (c0 * b1 - b0 * c1) / d,
						y: (a0 * c1 - c0 * a1) / d,
					};
				};
				
				const {x, y} = getIntersection([{x: 0, y: 0}, middle], [line, corner]);
				const progress = isThin ? (y - line.y) / (corner.y - line.y) : (x - line.x) / (corner.x - line.x);
				
				return {x, y, z: line.z / (1 - progress), c: line.y};
			};
			
			const getIntersect = (yIntersect, corner, right, top) => {
				const point0 = getIntersection(yIntersect, corner, right);
				const point1 = getIntersection(yIntersect, corner, top);
				
				const [point, vpEnd] = point0.z > point1.z ? [point0, {...right}] : [point1, {...top}];
				
				if (Math.sign(point[vpEnd.axis]) !== Math.sign(vpEnd[vpEnd.axis])) {
					vpEnd.x = -vpEnd.x;
					vpEnd.y = -vpEnd.y;
				}
				
				return {...point, vpEnd};
			};
			
			// the angle from 0,0 to the center of the video edge angled towards the viewport's upper-right corner
			const getQuadrantAngle = (isEvenQuadrant) => {
				const angle = (rotation.value + DEGREES[360]) % DEGREES[90];
				
				return isEvenQuadrant ? angle : DEGREES[90] - angle;
			};
			
			return () => {
				const isEvenQuadrant = (Math.floor(rotation.value / DEGREES[90]) + 3) % 2 === 0;
				const quadrantAngle = getQuadrantAngle(isEvenQuadrant);
				
				const progress = quadrantAngle / DEGREES[90] * -2 + 1;
				const progressAngles = {
					base: Math.atan(progress * viewportRatio),
					side: Math.atan(progress * viewportRatioInverse),
				};
				const progressCosines = {
					base: Math.cos(progressAngles.base),
					side: Math.cos(progressAngles.side),
				};
				
				const fitZoom = zoom.getVideoFit(true);
				const points = getPoints(fitZoom, quadrantAngle >= DEGREES[45]);
				
				const sideIntersection = getIntersect(
					((cornerAngle) => ({
						x: 0,
						y: (halfDimensions.video.height - halfDimensions.video.width * Math.tan(cornerAngle)) / video.clientHeight,
						z: halfDimensions.viewport.width / (progressCosines.side * Math.abs(halfDimensions.video.width / Math.cos(cornerAngle))),
					}))(quadrantAngle + progressAngles.side),
					isEvenQuadrant ? {x: -0.5, y: 0.5} : {x: 0.5, y: 0.5},
					...points,
				);
				
				const baseIntersection = getIntersect(
					((cornerAngle) => ({
						x: 0,
						y: (halfDimensions.video.height - halfDimensions.video.width * Math.tan(cornerAngle)) / video.clientHeight,
						z: halfDimensions.viewport.height / (progressCosines.base * Math.abs(halfDimensions.video.width / Math.cos(cornerAngle))),
					}))(DEGREES[90] - quadrantAngle - progressAngles.base),
					isEvenQuadrant ? {x: 0.5, y: 0.5} : {x: -0.5, y: 0.5},
					...points,
				);
				
				const [originSide, originBase] = fitZoom.map((z) => ({x: 0, y: 0, z}));
				
				return isEvenQuadrant ?
					[...[originSide, sideIntersection], ...[originBase, baseIntersection]] :
					[...[originBase, baseIntersection], ...[originSide, sideIntersection]];
			};
		})();
		
		let zoomPoints;
		
		const getEnsureZoomPoints = (() => {
			const updateLog = [];
			let count = 0;
			
			return () => {
				const zoomPointCache = new DimensionCache(rotation);
				const callbackCache = new Cache(zoom);
				const id = count++;
				
				return () => {
					if (zoomPointCache.isStale()) {
						updateLog.length = 0;
						
						zoomPoints = getZoomPoints();
					}
					
					if (callbackCache.isStale() || !updateLog[id]) {
						updateLog[id] = true;
						
						return true;
					}
					
					return false;
				};
			};
		})();
		
		const handlers = {
			[LIMITS.static]: ({custom: ratio}) => {
				const bound = 0.5 + (ratio - 0.5) / zoom.value;
				
				this.x = Math.max(-bound, Math.min(bound, this.x));
				this.y = Math.max(-bound, Math.min(bound, this.y));
			},
			[LIMITS.fit]: (() => {
				let boundApplyFrame;
				
				const ensure = getEnsureZoomPoints();
				
				return () => {
					if (ensure()) {
						boundApplyFrame = getBoundApplyFrame(...zoomPoints);
					}
					
					boundApplyFrame();
				};
			})(),
		};
		
		const snapHandlers = {
			[LIMITS.fit]: (() => {
				const ensure = getEnsureZoomPoints();
				
				return () => {
					ensure();
					
					snapZoom(...zoomPoints);
					
					zoom.constrain();
				};
			})(),
		};
		
		return (doZoom = false) => {
			const {panLimit, snapPanLimit} = $config.get();
			
			if (doZoom) {
				snapHandlers[snapPanLimit.type]?.();
			}
			
			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(halfDimensions.viewport.width / zoom.value, halfDimensions.viewport.height / 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) * halfDimensions.video.width, (1 - top - bottom) * halfDimensions.video.height);
					
					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 = DEGREES[90] - rotation.value;
				
				crosshair.text.innerText = `${Math.floor((DEGREES[90] - rotation.value) / Math.PI * 180)}°\n≈${Math.round(angle / DEGREES[90]) % 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 = DEGREES[90];
				
				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 / DEGREES[90]) * DEGREES[90];
					
					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 {width, height} = halfDimensions.viewport;
		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${width - halfGap} ${startOuter}L${width - halfGap} ${startInner}L${width + halfGap} ${startInner}L${width + halfGap} ${startOuter}L${viewport.clientWidth} ${startOuter}`
			+ `L${viewport.clientWidth} ${endOuter}L${width + halfGap} ${endOuter}L${width + halfGap} ${endInner}L${width - halfGap} ${endInner}L${width - halfGap} ${endOuter}L0 ${endOuter}`
			+ 'Z\')';
		
		this.lines.vertical.style.clipPath = 'path(\''
			+ `M${startOuter} 0L${startOuter} ${height - halfGap}L${startInner} ${height - halfGap}L${startInner} ${height + halfGap}L${startOuter} ${height + halfGap}L${startOuter} ${viewport.clientHeight}`
			+ `L${endOuter} ${viewport.clientHeight}L${endOuter} ${height + halfGap}L${endInner} ${height + halfGap}L${endInner} ${height - halfGap}L${endOuter} ${height - 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(() => {
		handleVideoChange();
		
		glow.handleSizeChange?.();
	});
	
	const viewportObserver = new ResizeObserver(() => {
		handleViewportChange();
		
		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) => new Promise((resolve) => {
		for (const child of node.children) {
			if (child.matches(selector)) {
				resolve(selectors.length === 0 ? child : getNode(child, ...selectors));
				
				return;
			}
		}
		
		new MutationObserver((changes, observer) => {
			for (const {addedNodes} of changes) {
				for (const child of addedNodes) {
					if (child.matches(selector)) {
						resolve(selectors.length === 0 ? child : getNode(child, ...selectors));
						
						observer.disconnect();
						
						return;
					}
				}
			}
		}).observe(node, {childList: true});
	});
	
	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();
		}
		
		if (isEmbed) {
			video = document.body.querySelector(SELECTOR_VIDEO);
		} else {
			const pageManager = await getNode(document.body, 'ytd-app', '#content', 'ytd-page-manager');
			
			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']});
		}
		
		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();
		
		handleVideoChange();
		handleViewportChange();
		
		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();
	}
});

// failsafe config access

new MutationObserver((changes) => {
	for (const {addedNodes} of changes) {
		for (const node of addedNodes) {
			if (!node.classList.contains('ytp-contextmenu')) {
				continue;
			}
			
			const container = node.querySelector('.ytp-panel-menu');
			const option = container.lastElementChild.cloneNode(true);
			
			option.children[0].style.visibility = 'hidden';
			option.children[1].innerText = 'Configure Viewfinding';
			
			option.addEventListener('click', ({button}) => {
				if (button === 0) {
					actions.configure.onActive();
				}
			});
			
			container.appendChild(option);
			
			new ResizeObserver((_, observer) => {
				if (container.clientWidth === 0) {
					option.remove();
					
					observer.disconnect();
				}
			}).observe(container);
		}
	}
}).observe(document.body, {childList: true});
})();