YouTube Viewfinding

Zoom, rotate & crop YouTube videos

目前为 2025-04-05 提交的版本。查看 最新版本

// ==UserScript==
// @name        YouTube Viewfinding
// @version     0.12
// @description Zoom, rotate & crop YouTube videos
// @author      Callum Latham
// @namespace   https://gf.qytechs.cn/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.gf.qytechs.cn/scripts/446506/1537901/%24Config.js
// @grant       GM.setValue
// @grant       GM.getValue
// @grant       GM.deleteValue
// ==/UserScript==

/* global $Config */

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

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

const $config = new $Config(
	'VIEWFIND_TREE',
	(() => {
		const isCSSRule = (() => {
			const wrapper = document.createElement('style');
			const regex = /\s/g;
			
			return (property, text) => {
				const ruleText = `${property}:${text};`;
				
				document.head.appendChild(wrapper);
				wrapper.sheet.insertRule(`:not(*){${ruleText}}`);
				
				const [{style: {cssText}}] = wrapper.sheet.cssRules;
				
				wrapper.remove();
				
				return cssText.replaceAll(regex, '') === ruleText.replaceAll(regex, '') || `Must be a valid CSS ${property} rule`;
			};
		})();
		
		const getHideId = (() => {
			let id = -1;
			
			return () => ++id;
		})();
		
		const glowHideId = getHideId();
		
		return {
			get: (_, configs) => Object.assign(...configs),
			children: [
				{
					label: 'Controls',
					children: [
						{
							label: 'Keybinds',
							descendantPredicate: (children) => {
								const isMatch = ({children: a}, {children: b}) => {
									if (a.length !== b.length) {
										return false;
									}
									
									return a.every(({value: keyA}) => b.some(({value: keyB}) => keyA === keyB));
								};
								
								for (let i = 1; i < children.length; ++i) {
									if (children.slice(i).some((child) => isMatch(children[i - 1], child))) {
										return 'Another action has this key combination';
									}
								}
								
								return true;
							},
							get: (_, configs) => ({keys: Object.assign(...configs)}),
							children: (() => {
								const seed = {
									value: '',
									listeners: {
										keydown: (event) => {
											switch (event.key) {
											case 'Enter':
											case 'Escape':
												return;
											}
											
											event.preventDefault();
											
											event.target.value = event.code;
											
											event.target.dispatchEvent(new InputEvent('input'));
										},
									},
								};
								
								const getKeys = (children) => new Set(children.map(({value}) => value));
								
								const getNode = (label, keys, get) => ({
									label,
									seed,
									children: keys.map((value) => ({...seed, value})),
									get,
								});
								
								return [
									{
										label: 'Actions',
										get: (_, [toggle, ...controls]) => Object.assign(...controls.map(({id, keys}) => ({
											[id]: {
												toggle,
												keys,
											},
										}))),
										children: [
											{
												label: 'Toggle?',
												value: false,
												get: ({value}) => value,
											},
											...[
												['Pan / Zoom', ['KeyZ'], 'pan'],
												['Rotate', ['IntlBackslash'], 'rotate'],
												['Crop', ['KeyZ', 'IntlBackslash'], 'crop'],
											].map(([label, keys, id]) => getNode(label, keys, ({children}) => ({id, keys: getKeys(children)}))),
										],
									},
									getNode('Reset', ['KeyX'], ({children}) => ({reset: {keys: getKeys(children)}})),
									getNode('Configure', ['AltLeft', 'KeyX'], ({children}) => ({config: {keys: getKeys(children)}})),
								];
							})(),
						},
						{
							label: 'Scroll Speeds',
							get: (_, configs) => ({speeds: Object.assign(...configs)}),
							children: [
								{
									label: 'Zoom',
									value: -100,
									get: ({value}) => ({zoom: value / 150000}),
								},
								{
									label: 'Rotate',
									value: -100,
									// 150000 * (5 - 0.8) / 2π ≈ 100000
									get: ({value}) => ({rotate: value / 100000}),
								},
								{
									label: 'Crop',
									value: -100,
									get: ({value}) => ({crop: value / 300000}),
								},
							],
						},
						{
							label: 'Drag Inversions',
							get: (_, configs) => ({multipliers: Object.assign(...configs)}),
							children: [
								['Pan', 'pan'],
								['Rotate', 'rotate'],
								['Crop', 'crop'],
							].map(([label, key, value = false]) => ({
								label,
								value,
								get: ({value}) => ({[key]: value ? -1 : 1}),
							})),
						},
						{
							label: 'Click Movement Allowance (px)',
							value: 2,
							predicate: (value) => value >= 0 || 'Allowance must be positive',
							inputAttributes: {min: 0},
							get: ({value: clickCutoff}) => ({clickCutoff}),
						},
					],
				},
				{
					label: 'Behaviour',
					children: [
						...(() => {
							const typeNode = {
								label: 'Type',
								get: ({value}) => ({type: value}),
							};
							
							const staticNode = {
								label: 'Value (%)',
								predicate: (value) => value >= 0 || 'Limit must be positive',
								inputAttributes: {min: 0},
								get: ({value}) => ({custom: value / 100}),
							};
							
							const fitNode = {
								label: 'Glow Allowance (%)',
								predicate: (value) => value >= 0 || 'Allowance must be positive',
								inputAttributes: {min: 0},
								get: ({value}) => ({frame: value / 100}),
							};
							
							const options = Object.values(LIMITS);
							
							const getNode = (label, key, value, customValue, glowAllowance = 300) => {
								const staticId = getHideId();
								const fitId = getHideId();
								const onUpdate = (value) => ({
									hide: {
										[staticId]: value !== LIMITS.static,
										[fitId]: value !== LIMITS.fit,
									},
								});
								
								return {
									label,
									get: (_, configs) => ({[key]: Object.assign(...configs)}),
									children: [
										{...typeNode, value, options, onUpdate},
										{...staticNode, value: customValue, hideId: staticId},
										{...fitNode, value: glowAllowance, hideId: fitId},
									],
								};
							};
							
							return [
								getNode('Zoom In Limit', 'zoomInLimit', LIMITS.static, 500, 0),
								getNode('Zoom Out Limit', 'zoomOutLimit', LIMITS.static, 80),
								getNode('Pan Limit', 'panLimit', LIMITS.static, 50),
								{
									label: 'Snap Pan Limit',
									get: (_, configs) => ({snapPanLimit: Object.assign(...configs)}),
									children: ((hideId) => [
										{
											...typeNode,
											value: LIMITS.fit,
											options: [LIMITS.none, LIMITS.fit],
											onUpdate: (value) => ({hide: {[hideId]: value !== LIMITS.fit}}),
										},
										{...fitNode, value: 0, hideId},
									])(getHideId()),
								},
							];
						})(),
						{
							label: 'While Viewfinding',
							get: (_, configs) => {
								const {overlayKill, overlayHide, ...config} = Object.assign(...configs);
								
								return {
									active: {
										overlayRule: overlayKill && [overlayHide ? 'display' : 'pointer-events', 'none'],
										...config,
									},
								};
							},
							children: [
								{
									label: 'Pause Video?',
									value: false,
									get: ({value: pause}) => ({pause}),
								},
								{
									label: 'Hide Glow?',
									value: false,
									get: ({value: hideGlow}) => ({hideGlow}),
									hideId: glowHideId,
								},
								...((hideId) => [
									{
										label: 'Disable Overlay?',
										value: true,
										get: ({value: overlayKill}, configs) => Object.assign({overlayKill}, ...configs),
										onUpdate: (value) => ({hide: {[hideId]: !value}}),
										children: [
											{
												label: 'Hide Overlay?',
												value: false,
												get: ({value: overlayHide}) => ({overlayHide}),
												hideId,
											},
										],
									},
								])(getHideId()),
							],
						},
						
					],
				},
				{
					label: 'Glow',
					value: true,
					onUpdate: (value) => ({hide: {[glowHideId]: !value}}),
					get: ({value: on}, configs) => {
						if (!on) {
							return {};
						}
						
						const {turnover, ...config} = Object.assign(...configs);
						const sampleCount = Math.floor(config.fps * turnover);
						
						// avoid taking more samples than there's space for
						if (sampleCount > config.size) {
							const fps = config.size / turnover;
							
							return {
								glow: {
									...config,
									sampleCount: config.size,
									interval: 1000 / fps,
									fps,
								},
							};
						}
						
						return {
							glow: {
								...config,
								interval: 1000 / config.fps,
								sampleCount,
							},
						};
					},
					children: [
						(() => {
							const [seed, getChild] = (() => {
								const options = ['blur', 'brightness', 'contrast', 'drop-shadow', 'grayscale', 'hue-rotate', 'invert', 'opacity', 'saturate', 'sepia'];
								const ids = {};
								const hide = {};
								
								for (const option of options) {
									ids[option] = getHideId();
									
									hide[ids[option]] = true;
								}
								
								const min0Amount = {
									label: 'Amount (%)',
									value: 100,
									predicate: (value) => value >= 0 || 'Amount must be positive',
									inputAttributes: {min: 0},
								};
								
								const max100Amount = {
									label: 'Amount (%)',
									value: 0,
									predicate: (value) => {
										if (value < 0) {
											return 'Amount must be positive';
										}
										
										return value <= 100 || 'Amount may not exceed 100%';
									},
									inputAttributes: {min: 0, max: 100},
								};
								
								const getScaled = (value) => `calc(${value}px/var(${VAR_ZOOM}))`;
								
								const root = {
									label: 'Function',
									options,
									value: options[0],
									get: ({value}, configs) => {
										const config = Object.assign(...configs);
										
										switch (value) {
										case options[0]:
											return {
												filter: config.blurScale ? `blur(${config.blur}px)` : `blur(${getScaled(config.blur)})`,
												blur: {
													x: config.blur,
													y: config.blur,
													scale: config.blurScale,
												},
											};
										
										case options[3]:
											return {
												filter: config.shadowScale ?
													`drop-shadow(${config.shadow} ${config.shadowX}px ${config.shadowY}px ${config.shadowSpread}px)` :
													`drop-shadow(${config.shadow} ${getScaled(config.shadowX)} ${getScaled(config.shadowY)} ${getScaled(config.shadowSpread)})`,
												blur: {
													x: config.shadowSpread + Math.abs(config.shadowX),
													y: config.shadowSpread + Math.abs(config.shadowY),
													scale: config.shadowScale,
												},
											};
										
										case options[5]:
											return {filter: `hue-rotate(${config.hueRotate}deg)`};
										}
										
										return {filter: `${value}(${config[value]}%)`};
									},
									onUpdate: (value) => ({hide: {...hide, [ids[value]]: false}}),
								};
								
								const children = {
									'blur': [
										{
											label: 'Distance (px)',
											value: 0,
											get: ({value}) => ({blur: value}),
											predicate: (value) => value >= 0 || 'Distance must be positive',
											inputAttributes: {min: 0},
											hideId: ids.blur,
										},
										{
											label: 'Scale?',
											value: false,
											get: ({value}) => ({blurScale: value}),
											hideId: ids.blur,
										},
									],
									'brightness': [
										{
											...min0Amount,
											hideId: ids.brightness,
											get: ({value}) => ({brightness: value}),
										},
									],
									'contrast': [
										{
											...min0Amount,
											hideId: ids.contrast,
											get: ({value}) => ({contrast: value}),
										},
									],
									'drop-shadow': [
										{
											label: 'Colour',
											input: 'color',
											value: '#FFFFFF',
											get: ({value}) => ({shadow: value}),
											hideId: ids['drop-shadow'],
										},
										{
											label: 'Horizontal Offset (px)',
											value: 0,
											get: ({value}) => ({shadowX: value}),
											hideId: ids['drop-shadow'],
										},
										{
											label: 'Vertical Offset (px)',
											value: 0,
											get: ({value}) => ({shadowY: value}),
											hideId: ids['drop-shadow'],
										},
										{
											label: 'Spread (px)',
											value: 0,
											predicate: (value) => value >= 0 || 'Spread must be positive',
											inputAttributes: {min: 0},
											get: ({value}) => ({shadowSpread: value}),
											hideId: ids['drop-shadow'],
										},
										{
											label: 'Scale?',
											value: true,
											get: ({value}) => ({shadowScale: value}),
											hideId: ids['drop-shadow'],
										},
									],
									'grayscale': [
										{
											...max100Amount,
											hideId: ids.grayscale,
											get: ({value}) => ({grayscale: value}),
										},
									],
									'hue-rotate': [
										{
											label: 'Angle (deg)',
											value: 0,
											get: ({value}) => ({hueRotate: value}),
											hideId: ids['hue-rotate'],
										},
									],
									'invert': [
										{
											...max100Amount,
											hideId: ids.invert,
											get: ({value}) => ({invert: value}),
										},
									],
									'opacity': [
										{
											...max100Amount,
											value: 100,
											hideId: ids.opacity,
											get: ({value}) => ({opacity: value}),
										},
									],
									'saturate': [
										{
											...min0Amount,
											hideId: ids.saturate,
											get: ({value}) => ({saturate: value}),
										},
									],
									'sepia': [
										{
											...max100Amount,
											hideId: ids.sepia,
											get: ({value}) => ({sepia: value}),
										},
									],
								};
								
								return [
									{...root, children: Object.values(children).flat()}, (id, ...values) => {
										const replacements = [];
										
										for (const [i, child] of children[id].entries()) {
											replacements.push({...child, value: values[i]});
										}
										
										return {
											...root,
											value: id,
											children: Object.values({...children, [id]: replacements}).flat(),
										};
									},
								];
							})();
							
							return {
								label: 'Filter',
								get: (_, configs) => {
									const scaled = {x: 0, y: 0};
									const unscaled = {x: 0, y: 0};
									
									let filter = '';
									
									for (const config of configs) {
										filter += config.filter;
										
										if ('blur' in config) {
											const target = config.blur.scale ? scaled : unscaled;
											
											target.x = Math.max(target.x, config.blur.x);
											target.y = Math.max(target.y, config.blur.y);
										}
									}
									
									return {filter, blur: {scaled, unscaled}};
								},
								children: [
									getChild('saturate', 150),
									getChild('brightness', 150),
									getChild('blur', 25, false),
								],
								seed,
							};
						})(),
						{
							label: 'Update',
							childPredicate: ([{value: fps}, {value: turnover}]) => fps * turnover >= 1 || `${turnover} second turnover cannot be achieved at ${fps} hertz`,
							children: [
								{
									label: 'Frequency (Hz)',
									value: 15,
									predicate: (value) => {
										if (value > 144) {
											return 'Update frequency may not be above 144 hertz';
										}
										
										return value >= 0 || 'Update frequency must be positive';
									},
									inputAttributes: {min: 0, max: 144},
									get: ({value: fps}) => ({fps}),
								},
								{
									label: 'Turnover Time (s)',
									value: 3,
									predicate: (value) => value >= 0 || 'Turnover time must be positive',
									inputAttributes: {min: 0},
									get: ({value: turnover}) => ({turnover}),
								},
								{
									label: 'Reverse?',
									value: false,
									get: ({value: doFlip}) => ({doFlip}),
								},
							],
						},
						{
							label: 'Size (px)',
							value: 50,
							predicate: (value) => value >= 0 || 'Size must be positive',
							inputAttributes: {min: 0},
							get: ({value}) => ({size: value}),
						},
						{
							label: 'End Point (%)',
							value: 103,
							predicate: (value) => value >= 0 || 'End point must be positive',
							inputAttributes: {min: 0},
							get: ({value}) => ({end: value / 100}),
						},
					].map((node) => ({...node, hideId: glowHideId})),
				},
				{
					label: 'Interfaces',
					children: [
						{
							label: 'Crop',
							get: (_, configs) => ({crop: Object.assign(...configs)}),
							children: [
								{
									label: 'Colours',
									get: (_, configs) => ({colour: Object.assign(...configs)}),
									children: [
										{
											label: 'Fill',
											get: (_, [colour, opacity]) => ({fill: `${colour}${opacity}`}),
											children: [
												{
													label: 'Colour',
													value: '#808080',
													input: 'color',
													get: ({value}) => value,
												},
												{
													label: 'Opacity (%)',
													value: 40,
													predicate: (value) => {
														if (value < 0) {
															return 'Opacity must be positive';
														}
														
														return value <= 100 || 'Opacity may not exceed 100%';
													},
													inputAttributes: {min: 0, max: 100},
													get: ({value}) => Math.round(255 * value / 100).toString(16),
												},
											],
										},
										{
											label: 'Shadow',
											value: '#000000',
											input: 'color',
											get: ({value: shadow}) => ({shadow}),
										},
										{
											label: 'Border',
											value: '#ffffff',
											input: 'color',
											get: ({value: border}) => ({border}),
										},
									],
								},
								{
									label: 'Handle Size (%)',
									value: 6,
									predicate: (value) => {
										if (value < 0) {
											return 'Size must be positive';
										}
										
										return value <= 50 || 'Size may not exceed 50%';
									},
									inputAttributes: {min: 0, max: 50},
									get: ({value}) => ({handle: value / 100}),
								},
							],
						},
						{
							label: 'Crosshair',
							get: (value, configs) => ({crosshair: Object.assign(...configs)}),
							children: [
								{
									label: 'Outer Thickness (px)',
									value: 3,
									predicate: (value) => value >= 0 || 'Thickness must be positive',
									inputAttributes: {min: 0},
									get: ({value: outer}) => ({outer}),
								},
								{
									label: 'Inner Thickness (px)',
									value: 1,
									predicate: (value) => value >= 0 || 'Thickness must be positive',
									inputAttributes: {min: 0},
									get: ({value: inner}) => ({inner}),
								},
								{
									label: 'Inner Diameter (px)',
									value: 157,
									predicate: (value) => value >= 0 || 'Diameter must be positive',
									inputAttributes: {min: 0},
									get: ({value: gap}) => ({gap}),
								},
								((hideId) => ({
									label: 'Text',
									value: true,
									onUpdate: (value) => ({hide: {[hideId]: !value}}),
									get: ({value}, configs) => {
										if (!value) {
											return {};
										}
										
										const {translateX, translateY, ...config} = Object.assign(...configs);
										
										return {
											text: {
												translate: {
													x: translateX,
													y: translateY,
												},
												...config,
											},
										};
									},
									children: [
										{
											label: 'Font',
											value: '30px "Harlow Solid", cursive',
											predicate: isCSSRule.bind(null, 'font'),
											get: ({value: font}) => ({font}),
										},
										{
											label: 'Position (%)',
											get: (_, configs) => ({position: Object.assign(...configs)}),
											children: ['x', 'y'].map((label) => ({
												label,
												value: 0,
												predicate: (value) => Math.abs(value) <= 50 || 'Position must be on-screen',
												inputAttributes: {min: -50, max: 50},
												get: ({value}) => ({[label]: value + 50}),
											})),
										},
										{
											label: 'Offset (px)',
											get: (_, configs) => ({offset: Object.assign(...configs)}),
											children: [
												{
													label: 'x',
													value: -6,
													get: ({value: x}) => ({x}),
												},
												{
													label: 'y',
													value: -25,
													get: ({value: y}) => ({y}),
												},
											],
										},
										(() => {
											const options = ['Left', 'Center', 'Right'];
											
											return {
												label: 'Alignment',
												value: options[2],
												options,
												get: ({value}) => ({align: value.toLowerCase(), translateX: options.indexOf(value) * -50}),
											};
										})(),
										(() => {
											const options = ['Top', 'Middle', 'Bottom'];
											
											return {
												label: 'Baseline',
												value: options[0],
												options,
												get: ({value}) => ({translateY: options.indexOf(value) * -50}),
											};
										})(),
										{
											label: 'Line height (%)',
											value: 90,
											predicate: (value) => value >= 0 || 'Height must be positive',
											inputAttributes: {min: 0},
											get: ({value}) => ({height: value / 100}),
										},
									].map((node) => ({...node, hideId})),
								}))(getHideId()),
								{
									label: 'Colours',
									get: (_, configs) => ({colour: Object.assign(...configs)}),
									children: [
										{
											label: 'Fill',
											value: '#ffffff',
											input: 'color',
											get: ({value: fill}) => ({fill}),
										},
										{
											label: 'Shadow',
											value: '#000000',
											input: 'color',
											get: ({value: shadow}) => ({shadow}),
										},
									],
								},
							],
						},
					],
				},
			],
		};
	})(),
	{
		headBase: '#c80000',
		headButtonExit: '#000000',
		borderHead: '#ffffff',
		borderTooltip: '#c80000',
		width: Math.min(90, screen.width / 16),
		height: 90,
	},
	{
		zIndex: 10000,
		scrollbarColor: 'initial',
	},
);

const CLASS_VIEWFINDER = 'viewfind-element';
const PI_HALVES = [Math.PI / 2, Math.PI, 3 * Math.PI / 2, Math.PI * 2];
const SELECTOR_VIDEO = '#movie_player video.html5-main-video';

// STATE

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

let stopped = true;
let stopDrag;

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

// ROTATION HELPERS

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

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

// CSS HELPER

const css = new function () {
	this.has = (name) => document.body.classList.contains(name);
	this.tag = (name, doAdd = true) => document.body.classList[doAdd ? 'add' : 'remove'](name);
	
	this.getSelector = (...classes) => `body.${classes.join('.')}`;
	
	const getSheet = () => {
		const element = document.createElement('style');
		
		document.head.appendChild(element);
		
		return element.sheet;
	};
	
	const getRuleString = (selector, ...declarations) => `${selector}{${declarations.map(([property, value]) => `${property}:${value};`).join('')}}`;
	
	this.add = function (...rule) {
		this.insertRule(getRuleString(...rule));
	}.bind(getSheet());
	
	this.Toggleable = class {
		static sheet = getSheet();
		
		static active = [];
		
		static id = 0;
		
		static add(rule, id) {
			this.sheet.insertRule(rule, this.active.length);
			
			this.active.push(id);
		}
		
		static remove(id) {
			let index = this.active.indexOf(id);
			
			while (index >= 0) {
				this.sheet.deleteRule(index);
				
				this.active.splice(index, 1);
				
				index = this.active.indexOf(id);
			}
		}
		
		id = this.constructor.id++;
		
		add(...rule) {
			this.constructor.add(getRuleString(...rule), this.id);
		}
		
		remove() {
			this.constructor.remove(this.id);
		}
	};
}();

// ACTION MANAGER

const enabler = new function () {
	this.CLASS_ABLE = 'viewfind-action-able';
	this.CLASS_DRAGGING = 'viewfind-action-dragging';
	
	this.keys = new Set();
	
	this.didPause = false;
	this.isHidingGlow = false;
	
	this.setActive = (action) => {
		const {active, keys} = $config.get();
		
		if (active.hideGlow && Boolean(action) !== this.isHidingGlow) {
			if (action) {
				this.isHidingGlow = true;
				
				glow.hide();
			} else if (this.isHidingGlow) {
				this.isHidingGlow = false;
				
				glow.show();
			}
		}
		
		this.activeAction?.onInactive?.();
		
		if (action) {
			this.activeAction = action;
			this.toggled = keys[action.CODE].toggle;
			
			action.onActive?.();
			
			if (active.pause && !video.paused) {
				video.pause();
				
				this.didPause = true;
			}
			
			return;
		}
		
		if (this.didPause) {
			video.play();
			
			this.didPause = false;
		}
		
		this.activeAction = this.toggled = undefined;
	};
	
	this.handleChange = () => {
		if (stopped || stopDrag || video.ended) {
			return;
		}
		
		const {keys} = $config.get();
		
		let activeAction;
		
		for (const action of Object.values(actions)) {
			if (
				!this.keys.isSupersetOf(keys[action.CODE].keys) || activeAction && ('toggle' in keys[action.CODE] ?
					!('toggle' in keys[activeAction.CODE]) || keys[activeAction.CODE].keys.size >= keys[action.CODE].keys.size :
					!('toggle' in keys[activeAction.CODE]) && keys[activeAction.CODE].keys.size >= keys[action.CODE].keys.size)
			) {
				if ('CLASS_ABLE' in action) {
					css.tag(action.CLASS_ABLE, false);
				}
				
				continue;
			}
			
			if (activeAction && 'CLASS_ABLE' in activeAction) {
				css.tag(activeAction.CLASS_ABLE, false);
			}
			
			activeAction = action;
		}
		
		if (activeAction === this.activeAction) {
			return;
		}
		
		if (activeAction) {
			if ('CLASS_ABLE' in activeAction) {
				css.tag(activeAction.CLASS_ABLE);
				
				css.tag(this.CLASS_ABLE);
				
				this.setActive(activeAction);
				
				return;
			}
			
			this.activeAction?.onInactive?.();
			
			activeAction.onActive();
			
			this.activeAction = activeAction;
		}
		
		css.tag(this.CLASS_ABLE, false);
		
		this.setActive(false);
	};
	
	this.stop = () => {
		css.tag(this.CLASS_ABLE, false);
		
		for (const action of Object.values(actions)) {
			if ('CLASS_ABLE' in action) {
				css.tag(action.CLASS_ABLE, false);
			}
		}
		
		this.setActive(false);
	};
	
	this.updateConfig = (() => {
		const rule = new css.Toggleable();
		const selector = `${css.getSelector(this.CLASS_ABLE)} #contentContainer.tp-yt-app-drawer[swipe-open]::after`
			+ `,${css.getSelector(this.CLASS_ABLE)} #movie_player > .html5-video-container ~ :not(.${CLASS_VIEWFINDER})`;
		
		return () => {
			const {overlayRule} = $config.get().active;
			
			rule.remove();
			
			if (overlayRule) {
				rule.add(selector, overlayRule);
			}
		};
	})();
	
	$config.ready.then(() => {
		this.updateConfig();
	});
	
	// insertion order decides priority
	css.add(`${css.getSelector(this.CLASS_DRAGGING)} #movie_player`, ['cursor', 'grabbing']);
	css.add(`${css.getSelector(this.CLASS_ABLE)} #movie_player`, ['cursor', 'grab']);
}();

// ELEMENT CONTAINER SETUP

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

// MODIFIERS

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

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

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

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

const position = new function () {
	this.x = this.y = 0;
	
	this.getValues = () => ({x: this.x, y: this.y});
	
	this.reset = () => {
		this.x = this.y = 0;
		
		video.style.removeProperty('translate');
	};
	
	this.apply = () => {
		video.style.setProperty('transform-origin', `${(0.5 + this.x) * 100}% ${(0.5 - this.y) * 100}%`);
		video.style.setProperty('translate', `${-this.x * 100}% ${this.y * 100}%`);
		
		delete actions.reset.restore;
	};
	
	this.constrain = (() => {
		const applyFrameValues = (lowCorner, highCorner, sub, main) => {
			this[sub] = Math.max(-lowCorner[sub], Math.min(highCorner[sub], this[sub]));
			
			const progress = (this[sub] + lowCorner[sub]) / (highCorner[sub] + lowCorner[sub]);
			
			if (this[main] < 0) {
				const bound = Number.isNaN(progress) ?
					-lowCorner[main] :
					(lowCorner[main] - highCorner[main]) * progress - lowCorner[main];
				
				this[main] = Math.max(this[main], bound);
			} else {
				const bound = Number.isNaN(progress) ?
					lowCorner[main] :
					(highCorner[main] - lowCorner[main]) * progress + lowCorner[main];
				
				this[main] = Math.min(this[main], bound);
			}
		};
		
		const applyFrame = (firstCorner, secondCorner, firstCornerAngle, secondCornerAngle) => {
			// The anti-clockwise angle from the first (top left) corner
			const midPointAngle = (getTheta(0, 0, this.x, this.y) + PI_HALVES[1] + firstCornerAngle) % PI_HALVES[3];
			
			if (midPointAngle % PI_HALVES[1] < secondCornerAngle) {
				// Frame is x-bound
				const [lowCorner, highCorner] = this.x >= 0 ? [firstCorner, secondCorner] : [secondCorner, firstCorner];
				
				applyFrameValues(lowCorner, highCorner, 'y', 'x');
			} else {
				// Frame is y-bound
				const [lowCorner, highCorner] = this.y >= 0 ? [firstCorner, secondCorner] : [secondCorner, firstCorner];
				
				applyFrameValues(lowCorner, highCorner, 'x', 'y');
			}
		};
		
		const getBoundApplyFrame = (() => {
			const getCorner = (first, second) => {
				if (zoom.value < first.z) {
					return {x: 0, y: 0};
				}
				
				if (zoom.value < second.z) {
					const progress = (1 / zoom.value - 1 / first.z) / (1 / second.z - 1 / first.z);
					
					return {
						x: Math.max(0, progress * (second.x - first.x) + first.x),
						y: Math.max(0, progress * (second.y - first.y) + first.y),
					};
				}
				
				return {
					x: Math.max(0, 0.5 - (0.5 - second.x) / (zoom.value / second.z)),
					y: Math.max(0, 0.5 - (0.5 - second.y) / (zoom.value / second.z)),
				};
			};
			
			return (first0, second0, first1, second1) => {
				const fFirstCorner = getCorner(first0, second0);
				const fSecondCorner = getCorner(first1, second1);
				
				const fFirstCornerAngle = getTheta(0, 0, fFirstCorner.x, fFirstCorner.y);
				const fSecondCornerAngle = fFirstCornerAngle + getTheta(0, 0, fSecondCorner.x, fSecondCorner.y);
				
				for (const [same, different] of [['x', 'y'], ['y', 'x']]) {
					if (fFirstCorner[same] === fSecondCorner[same]) {
						if (fFirstCorner[different] > fSecondCorner[different]) {
							return applyFrame.bind(null, fFirstCorner, fFirstCorner, fFirstCornerAngle, fFirstCornerAngle);
						}
						
						return applyFrame.bind(null, fSecondCorner, fSecondCorner, fSecondCornerAngle, fSecondCornerAngle);
					}
				}
				
				return applyFrame.bind(null, fFirstCorner, fSecondCorner, fFirstCornerAngle, fSecondCornerAngle);
			};
		})();
		
		// https://math.stackexchange.com/questions/2223691/intersect-2-lines-at-the-same-ratio-through-a-point
		const snapZoom = (() => {
			const isAbove = (x, y, m, c) => m * x + c < y;
			
			const getPSecond = (low, high) => 1 - low / high;
			const getPFirst = (low, high, target) => (target - low) / (high - low);
			
			const getProgressed = (p, [fromX, fromY], [toX, toY]) => [p * (toX - fromX) + fromX, p * (toY - fromY) + fromY];
			
			const getFlipped = (first, second, flipX, flipY) => {
				const flippedFirst = [];
				const flippedSecond = [];
				const corner = [];
				
				if (flipX) {
					flippedFirst[0] = -first.x;
					flippedSecond[0] = -second.x;
					corner[0] = -0.5;
				} else {
					flippedFirst[0] = first.x;
					flippedSecond[0] = second.x;
					corner[0] = 0.5;
				}
				
				if (flipY) {
					flippedFirst[1] = -first.y;
					flippedSecond[1] = -second.y;
					corner[1] = -0.5;
				} else {
					flippedFirst[1] = first.y;
					flippedSecond[1] = second.y;
					corner[1] = 0.5;
				}
				
				return [flippedFirst, flippedSecond, corner];
			};
			
			const getIntersectPSecond = ([[g, e], [f, d]], [[k, i], [j, h]], doFlip) => {
				const x = Math.abs(position.x);
				const y = Math.abs(position.y);
				
				const a = d * j - d * k - j * e + e * k - h * f + h * g + i * f - i * g;
				const b = d * k - d * x - e * k + e * x + j * e - k * e - j * y + k * y - h * g + h * x + i * g - i * x - f * i + g * i + f * y - g * y;
				const c = k * e - e * x - k * y - g * i + i * x + g * y;
				
				return (doFlip ? -b - Math.sqrt(b * b - 4 * a * c) : -b + Math.sqrt(b * b - 4 * a * c)) / (2 * a);
			};
			
			const applyZoomPairSecond = ([z, ...pair], doFlip) => {
				const p = getIntersectPSecond(...pair, doFlip);
				
				if (p >= 0) {
					zoom.value = p >= 1 ? Number.MAX_SAFE_INTEGER : z / (1 - p);
					
					return true;
				}
				
				return false;
			};
			
			const applyZoomPairFirst = ([z0, z1, ...pair], doFlip) => {
				const p = getIntersectPSecond(...pair, doFlip);
				
				if (p >= 0) {
					zoom.value = p * (z1 - z0) + z0;
					
					return true;
				}
				
				return false;
			};
			
			return (first0, second0, first1, second1) => {
				const getPairings = (flipX0, flipY0, flipX1, flipY1) => {
					const [flippedFirst0, flippedSecond0, corner0] = getFlipped(first0, second0, flipX0, flipY0);
					const [flippedFirst1, flippedSecond1, corner1] = getFlipped(first1, second1, flipX1, flipY1);
					
					if (second0.z > second1.z) {
						const progressedHigh = getProgressed(getPSecond(second1.z, second0.z), flippedSecond1, corner1);
						const pairHigh = [
							second0.z,
							[flippedSecond0, corner0],
							[progressedHigh, corner1],
						];
						
						if (second1.z > first0.z) {
							const progressedLow = getProgressed(getPFirst(first0.z, second0.z, second1.z), flippedFirst0, flippedSecond0);
							
							return [
								pairHigh,
								[
									second1.z,
									second0.z,
									[progressedLow, flippedSecond0],
									[flippedSecond1, progressedHigh],
								],
							];
						}
						
						const progressedLow = getProgressed(getPSecond(second1.z, first0.z), flippedSecond1, corner1);
						
						return [
							pairHigh,
							[
								first0.z,
								second0.z,
								[flippedFirst0, flippedSecond0],
								[progressedLow, progressedHigh],
							],
						];
					}
					
					const progressedHigh = getProgressed(getPSecond(second0.z, second1.z), flippedSecond0, corner0);
					const pairHigh = [
						second1.z,
						[progressedHigh, corner0],
						[flippedSecond1, corner1],
					];
					
					if (second0.z > first1.z) {
						const progressedLow = getProgressed(getPFirst(first1.z, second1.z, second0.z), flippedFirst1, flippedSecond1);
						
						return [
							pairHigh,
							[
								second0.z,
								second1.z,
								[progressedLow, flippedSecond1],
								[flippedSecond0, progressedHigh],
							],
						];
					}
					
					const progressedLow = getProgressed(getPSecond(second0.z, first1.z), flippedSecond0, corner0);
					
					return [
						pairHigh,
						[
							first1.z,
							second1.z,
							[flippedFirst1, flippedSecond1],
							[progressedLow, progressedHigh],
						],
					];
				};
				
				const [pair0, pair1, doFlip = false] = (() => {
					const doInvert = position.x >= 0 === position.y < 0;
					
					if (doInvert) {
						const m = (second0.y - 0.5) / (second0.x - 0.5);
						const c = 0.5 - m * 0.5;
						
						if (isAbove(Math.abs(position.x), Math.abs(position.y), m, c)) {
							return [...getPairings(false, false, true, false), true];
						}
						
						return getPairings(false, false, false, true);
					}
					
					const m = (second1.y - 0.5) / (second1.x - 0.5);
					const c = 0.5 - m * 0.5;
					
					if (isAbove(Math.abs(position.x), Math.abs(position.y), m, c)) {
						return getPairings(true, false, false, false);
					}
					
					return [...getPairings(false, true, false, false), true];
				})();
				
				if (applyZoomPairSecond(pair0, doFlip) || applyZoomPairFirst(pair1, doFlip)) {
					return;
				}
				
				zoom.value = pair1[0];
			};
		})();
		
		const getZoomPoints = (mod) => {
			const [videoWidth, videoHeight] = (() => {
				const {glow} = $config.get();
				
				if (glow) {
					const {scaled, unscaled} = glow.blur;
					
					return [
						(video.clientWidth + Math.max(0, glow.end * video.clientWidth - video.clientWidth + Math.max(unscaled.x, scaled.x * zoom.value)) * mod) / 2,
						(video.clientHeight + Math.max(0, glow.end * video.clientHeight - video.clientHeight + Math.max(unscaled.y, scaled.y * zoom.value)) * mod) / 2,
					];
				}
				
				return [video.clientWidth / 2, video.clientHeight / 2];
			})();
			
			const viewportWidth = viewport.clientWidth / 2;
			const viewportHeight = viewport.clientHeight / 2;
			
			const quadrant = Math.floor(rotation.value / PI_HALVES[0]) + 3;
			
			const yAngle = (() => {
				const angle = (rotation.value + PI_HALVES[3]) % PI_HALVES[0];
				
				return quadrant % 2 === 0 ? angle : PI_HALVES[0] - angle;
			})();
			
			const progress = yAngle / PI_HALVES[0] * -2 + 1;
			
			const [cornerAVars, cornerBVars] = [
				[
					(() => {
						const angleA = Math.atan(progress * viewportWidth / viewportHeight);
						const angleB = angleA + yAngle;
						
						const h = Math.abs(videoHeight / Math.cos(angleB));
						
						const z = viewportHeight / (Math.cos(angleA) * h);
						const x = (videoWidth - videoHeight * Math.tan(angleB)) / video.clientWidth;
						
						return {
							x,
							y: 0,
							z,
						};
					})(),
					(() => {
						const angleA = Math.atan(progress * viewportWidth / viewportHeight);
						const angleB = PI_HALVES[0] - angleA - yAngle;
						
						const h = Math.abs(videoWidth / Math.cos(angleB));
						
						const z = viewportHeight / (Math.cos(angleA) * h);
						const y = (videoHeight - videoWidth * Math.tan(angleB)) / video.clientHeight;
						
						return {
							x: 0,
							y,
							z,
						};
					})(),
				],
				[
					(() => {
						const angleA = Math.atan(progress * viewportHeight / viewportWidth);
						const angleB = angleA + yAngle;
						
						const h = Math.abs(videoWidth / Math.cos(angleB));
						
						const z = viewportWidth / (Math.cos(angleA) * h);
						const y = (videoHeight - videoWidth * Math.tan(angleB)) / video.clientHeight;
						
						return {
							x: 0,
							y,
							z,
						};
					})(),
					(() => {
						const angleA = Math.atan(progress * viewportHeight / viewportWidth);
						const angleB = PI_HALVES[0] - angleA - yAngle;
						
						const h = Math.abs(videoHeight / Math.cos(angleB));
						
						const z = viewportWidth / (Math.cos(angleA) * h);
						const x = (videoWidth - videoHeight * Math.tan(angleB)) / video.clientWidth;
						
						return {
							x,
							y: 0,
							z,
						};
					})(),
				],
			].map(([xCorner, yCorner]) => xCorner.z < yCorner.z ? [xCorner, yCorner] : [yCorner, xCorner]);
			
			return quadrant % 2 === 1 ? [...cornerAVars, ...cornerBVars] : [...cornerBVars, ...cornerAVars];
		};
		
		const handlers = {
			[LIMITS.static]: ({custom: ratio}) => {
				const bound = 0.5 + (ratio - 0.5) / zoom.value;
				
				position.x = Math.max(-bound, Math.min(bound, position.x));
				position.y = Math.max(-bound, Math.min(bound, position.y));
			},
			[LIMITS.fit]: (() => {
				const cache = new ConfigCache(rotation, zoom);
				
				let boundApplyFrame;
				
				return ({frame}) => {
					if (cache.isStale()) {
						boundApplyFrame = getBoundApplyFrame(...getZoomPoints(frame));
					}
					
					boundApplyFrame();
				};
			})(),
		};
		
		const snapHandlers = {
			[LIMITS.fit]: (() => {
				const cache = new ConfigCache(rotation, zoom);
				
				let boundSnapZoom;
				
				return ({frame}) => {
					if (cache.isStale()) {
						boundSnapZoom = snapZoom.bind(null, ...getZoomPoints(frame));
					}
					
					boundSnapZoom();
					
					zoom.constrain();
				};
			})(),
		};
		
		return (doZoom = false) => {
			const {panLimit, snapPanLimit} = $config.get();
			
			if (doZoom) {
				snapHandlers[snapPanLimit.type]?.(snapPanLimit);
			}
			
			handlers[panLimit.type]?.(panLimit);
			
			this.apply();
		};
	})();
}();

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

// FUNCTIONALITY

const glow = (() => {
	const videoCanvas = new OffscreenCanvas(0, 0);
	const videoCtx = videoCanvas.getContext('2d', {alpha: false});
	
	const glowCanvas = document.createElement('canvas');
	const glowCtx = glowCanvas.getContext('2d', {alpha: false});
	
	glowCanvas.style.setProperty('position', 'absolute');
	
	class Sector {
		canvas = new OffscreenCanvas(0, 0);
		ctx = this.canvas.getContext('2d', {alpha: false});
		
		update(doFill) {
			if (doFill) {
				this.fill();
			} else {
				this.shift();
				this.take();
			}
			
			this.giveEdge();
			
			if (this.hasCorners) {
				this.giveCorners();
			}
		}
	}
	
	class Side extends Sector {
		setDimensions(doShiftRight, sWidth, sHeight, sx, sy, dx, dy, dWidth, dHeight) {
			this.canvas.width = sWidth;
			this.canvas.height = sHeight;
			
			this.shift = this.ctx.drawImage.bind(this.ctx, this.canvas, doShiftRight ? 1 : -1, 0);
			
			this.fill = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, 1, sHeight, 0, 0, sWidth, sHeight);
			this.take = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, 1, sHeight, doShiftRight ? 0 : sWidth - 1, 0, 1, sHeight);
			
			this.giveEdge = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);
			
			if (dy === 0) {
				this.hasCorners = false;
				
				return;
			}
			
			this.hasCorners = true;
			
			const giveCorner0 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, 1, dx, 0, dWidth, dy);
			const giveCorner1 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, sHeight - 1, sWidth, 1, dx, dy + dHeight, dWidth, dy);
			
			this.giveCorners = () => {
				giveCorner0();
				giveCorner1();
			};
		}
	}
	
	class Base extends Sector {
		setDimensions(doShiftDown, sWidth, sHeight, sx, sy, dx, dy, dWidth, dHeight) {
			this.canvas.width = sWidth;
			this.canvas.height = sHeight;
			
			this.shift = this.ctx.drawImage.bind(this.ctx, this.canvas, 0, doShiftDown ? 1 : -1);
			
			this.fill = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, sWidth, 1, 0, 0, sWidth, sHeight);
			this.take = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, sWidth, 1, 0, doShiftDown ? 0 : sHeight - 1, sWidth, 1);
			
			this.giveEdge = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);
			
			if (dx === 0) {
				this.hasCorners = false;
				
				return;
			}
			
			this.hasCorners = true;
			
			const giveCorner0 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, 1, sHeight, 0, dy, dx, dHeight);
			const giveCorner1 = glowCtx.drawImage.bind(glowCtx, this.canvas, sWidth - 1, 0, 1, sHeight, dx + dWidth, dy, dx, dHeight);
			
			this.giveCorners = () => {
				giveCorner0();
				giveCorner1();
			};
		}
		
		setClipPath(points) {
			this.clipPath = new Path2D();
			
			this.clipPath.moveTo(...points[0]);
			this.clipPath.lineTo(...points[1]);
			this.clipPath.lineTo(...points[2]);
			this.clipPath.closePath();
		}
		
		update(doFill) {
			glowCtx.save();
			
			glowCtx.clip(this.clipPath);
			
			super.update(doFill);
			
			glowCtx.restore();
		}
	}
	
	const components = {
		left: new Side(),
		right: new Side(),
		top: new Base(),
		bottom: new Base(),
	};
	
	const setComponentDimensions = (sampleCount, size, isInset, doFlip) => {
		const [croppedWidth, croppedHeight] = crop.getDimensions();
		const halfCanvas = {x: Math.ceil(glowCanvas.width / 2), y: Math.ceil(glowCanvas.height / 2)};
		const halfVideo = {x: croppedWidth / 2, y: croppedHeight / 2};
		const dWidth = Math.ceil(Math.min(halfVideo.x, size));
		const dHeight = Math.ceil(Math.min(halfVideo.y, size));
		const [dWidthScale, dHeightScale, sideWidth, sideHeight] = isInset ?
			[0, 0, videoCanvas.width / croppedWidth * glowCanvas.width, videoCanvas.height / croppedHeight * glowCanvas.height] :
			[halfCanvas.x - halfVideo.x, halfCanvas.y - halfVideo.y, croppedWidth, croppedHeight];
		
		components.left.setDimensions(!doFlip, sampleCount, videoCanvas.height, 0, 0, 0, dHeightScale, dWidth, sideHeight);
		
		components.right.setDimensions(doFlip, sampleCount, videoCanvas.height, videoCanvas.width - 1, 0, glowCanvas.width - dWidth, dHeightScale, dWidth, sideHeight);
		
		components.top.setDimensions(!doFlip, videoCanvas.width, sampleCount, 0, 0, dWidthScale, 0, sideWidth, dHeight);
		components.top.setClipPath([[0, 0], [halfCanvas.x, halfCanvas.y], [glowCanvas.width, 0]]);
		
		components.bottom.setDimensions(doFlip, videoCanvas.width, sampleCount, 0, videoCanvas.height - 1, dWidthScale, glowCanvas.height - dHeight, sideWidth, dHeight);
		components.bottom.setClipPath([[0, glowCanvas.height], [halfCanvas.x, halfCanvas.y], [glowCanvas.width, glowCanvas.height]]);
	};
	
	class Instance {
		constructor() {
			const {filter, sampleCount, size, end, doFlip} = $config.get().glow;
			
			// Setup canvases
			
			glowCanvas.style.setProperty('filter', filter);
			
			[glowCanvas.width, glowCanvas.height] = crop.getDimensions().map((dimension) => dimension * end);
			
			glowCanvas.style.setProperty('left', `${crop.left * 100 + (1 - end) * (1 - crop.left - crop.right) * 50}%`);
			glowCanvas.style.setProperty('top', `${crop.top * 100 + (1 - end) * (1 - crop.top - crop.bottom) * 50}%`);
			
			[videoCanvas.width, videoCanvas.height] = crop.getDimensions(video.videoWidth, video.videoHeight);
			
			setComponentDimensions(sampleCount, size, end <= 1, doFlip);
			
			this.update(true);
		}
		
		update(doFill = false) {
			videoCtx.drawImage(
				video,
				crop.left * video.videoWidth,
				crop.top * video.videoHeight,
				video.videoWidth * (1 - crop.left - crop.right),
				video.videoHeight * (1 - crop.top - crop.bottom),
				0,
				0,
				videoCanvas.width,
				videoCanvas.height,
			);
			
			components.left.update(doFill);
			components.right.update(doFill);
			components.top.update(doFill);
			components.bottom.update(doFill);
		}
	}
	
	return new function () {
		const container = document.createElement('div');
		
		container.style.display = 'none';
		
		container.appendChild(glowCanvas);
		containers.background.appendChild(container);
		
		this.isHidden = false;
		
		let instance, startCopyLoop, stopCopyLoop;
		
		const play = () => {
			if (!video.paused && !this.isHidden && !enabler.isHidingGlow) {
				startCopyLoop?.();
			}
		};
		
		const fill = () => {
			if (!this.isHidden) {
				instance.update(true);
			}
		};
		
		const handleVisibilityChange = () => {
			if (document.hidden) {
				stopCopyLoop();
			} else {
				play();
			}
		};
		
		this.handleSizeChange = () => {
			instance = new Instance();
		};
		
		// set up pausing if glow isn't visible
		this.handleViewChange = (() => {
			const cache = new Cache(rotation, zoom);
			
			let corners;
			
			return (doForce = false) => {
				if (doForce || cache.isStale()) {
					corners = getRotatedCorners(viewport.clientWidth / 2 / zoom.value, viewport.clientHeight / 2 / zoom.value);
				}
				
				const videoX = position.x * video.clientWidth;
				const videoY = position.y * video.clientHeight;
				
				for (const corner of corners) {
					if (
						// unpause if the viewport extends more than 1 pixel beyond a video edge
						videoX + corner.x > (0.5 - crop.right) * video.clientWidth + 1
						|| videoX - corner.x < (crop.left - 0.5) * video.clientWidth - 1
						|| videoY + corner.y > (0.5 - crop.top) * video.clientHeight + 1
						|| videoY - corner.y < (crop.bottom - 0.5) * video.clientHeight - 1
					) {
						// fill if newly visible
						if (this.isHidden) {
							instance?.update(true);
						}
						
						this.isHidden = false;
						
						glowCanvas.style.removeProperty('visibility');
						
						play();
						
						return;
					}
				}
				
				this.isHidden = true;
				
				glowCanvas.style.visibility = 'hidden';
				
				stopCopyLoop?.();
			};
		})();
		
		const loop = {};
		
		this.start = () => {
			const config = $config.get().glow;
			
			if (!config) {
				return;
			}
			
			if (!enabler.isHidingGlow) {
				container.style.removeProperty('display');
			}
			
			// todo handle this?
			if (crop.left + crop.right >= 1 || crop.top + crop.bottom >= 1) {
				return;
			}
			
			let loopId = -1;
			
			if (loop.interval !== config.interval || loop.fps !== config.fps) {
				loop.interval = config.interval;
				loop.fps = config.fps;
				loop.wasSlow = false;
				loop.throttleCount = 0;
			}
			
			stopCopyLoop = () => ++loopId;
			
			instance = new Instance();
			
			startCopyLoop = async () => {
				const id = ++loopId;
				
				await new Promise((resolve) => {
					window.setTimeout(resolve, config.interval);
				});
				
				while (id === loopId) {
					const startTime = Date.now();
					
					instance.update();
					
					const delay = loop.interval - (Date.now() - startTime);
					
					if (delay <= 0) {
						if (loop.wasSlow) {
							loop.interval = 1000 / (loop.fps - ++loop.throttleCount);
						}
						
						loop.wasSlow = !loop.wasSlow;
						
						continue;
					}
					
					if (delay > 2 && loop.throttleCount > 0) {
						console.warn(`[${GM.info.script.name}] Glow update frequency reduced from ${loop.fps} hertz to ${loop.fps - loop.throttleCount} hertz due to poor performance.`);
						
						loop.fps -= loop.throttleCount;
						
						loop.throttleCount = 0;
					}
					
					loop.wasSlow = false;
					
					await new Promise((resolve) => {
						window.setTimeout(resolve, delay);
					});
				}
			};
			
			play();
			
			video.addEventListener('pause', stopCopyLoop);
			video.addEventListener('play', play);
			video.addEventListener('seeked', fill);
			
			document.addEventListener('visibilitychange', handleVisibilityChange);
		};
		
		const priorCrop = {};
		
		this.hide = () => {
			Object.assign(priorCrop, crop);
			
			stopCopyLoop?.();
			
			container.style.display = 'none';
		};
		
		this.show = () => {
			if (Object.entries(priorCrop).some(([edge, value]) => crop[edge] !== value)) {
				this.reset();
			} else {
				play();
			}
			
			container.style.removeProperty('display');
		};
		
		this.stop = () => {
			this.hide();
			
			video.removeEventListener('pause', stopCopyLoop);
			video.removeEventListener('play', play);
			video.removeEventListener('seeked', fill);
			
			document.removeEventListener('visibilitychange', handleVisibilityChange);
			
			startCopyLoop = undefined;
			stopCopyLoop = undefined;
		};
		
		this.reset = () => {
			this.stop();
			
			this.start();
		};
	}();
})();

const peek = (stop = false) => {
	const prior = {
		zoom: zoom.value,
		rotation: rotation.value,
		crop: crop.getValues(),
		position: position.getValues(),
	};
	
	position.reset();
	rotation.reset();
	zoom.reset();
	crop.reset();
	
	glow[stop ? 'stop' : 'reset']();
	
	return () => {
		zoom.value = prior.zoom;
		rotation.value = prior.rotation;
		Object.assign(position, prior.position);
		Object.assign(crop, prior.crop);
		
		actions.crop.set(prior.crop);
		
		position.apply();
		rotation.apply();
		zoom.apply();
		crop.apply();
	};
};

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

const crosshair = new function () {
	this.container = document.createElement('div');
	
	this.lines = {
		horizontal: document.createElement('div'),
		vertical: document.createElement('div'),
	};
	
	this.text = document.createElement('div');
	
	const id = 'viewfind-crosshair';
	
	this.container.id = id;
	this.container.classList.add(CLASS_VIEWFINDER);
	
	css.add(`#${id}:not(${css.getSelector(actions.pan.CLASS_ABLE)} *):not(${css.getSelector(actions.rotate.CLASS_ABLE)} *)`, ['display', 'none']);
	
	this.lines.horizontal.style.position = this.lines.vertical.style.position = this.text.style.position = this.container.style.position = 'absolute';
	
	this.lines.horizontal.style.top = '50%';
	this.lines.horizontal.style.width = '100%';
	
	this.lines.vertical.style.left = '50%';
	this.lines.vertical.style.height = '100%';
	
	this.text.style.userSelect = 'none';
	
	this.container.style.top = '0';
	this.container.style.width = '100%';
	this.container.style.height = '100%';
	this.container.style.pointerEvents = 'none';
	
	this.container.append(this.lines.horizontal, this.lines.vertical);
	
	this.clip = () => {
		const {outer, inner, gap} = $config.get().crosshair;
		
		const thickness = Math.max(inner, outer);
		
		const halfWidth = viewport.clientWidth / 2;
		const halfHeight = viewport.clientHeight / 2;
		const halfGap = gap / 2;
		
		const startInner = (thickness - inner) / 2;
		const startOuter = (thickness - outer) / 2;
		
		const endInner = thickness - startInner;
		const endOuter = thickness - startOuter;
		
		this.lines.horizontal.style.clipPath = 'path(\''
			+ `M0 ${startOuter}L${halfWidth - halfGap} ${startOuter}L${halfWidth - halfGap} ${startInner}L${halfWidth + halfGap} ${startInner}L${halfWidth + halfGap} ${startOuter}L${viewport.clientWidth} ${startOuter}`
			+ `L${viewport.clientWidth} ${endOuter}L${halfWidth + halfGap} ${endOuter}L${halfWidth + halfGap} ${endInner}L${halfWidth - halfGap} ${endInner}L${halfWidth - halfGap} ${endOuter}L0 ${endOuter}`
			+ 'Z\')';
		
		this.lines.vertical.style.clipPath = 'path(\''
			+ `M${startOuter} 0L${startOuter} ${halfHeight - halfGap}L${startInner} ${halfHeight - halfGap}L${startInner} ${halfHeight + halfGap}L${startOuter} ${halfHeight + halfGap}L${startOuter} ${viewport.clientHeight}`
			+ `L${endOuter} ${viewport.clientHeight}L${endOuter} ${halfHeight + halfGap}L${endInner} ${halfHeight + halfGap}L${endInner} ${halfHeight - halfGap}L${endOuter} ${halfHeight - halfGap}L${endOuter} 0`
			+ 'Z\')';
	};
	
	this.updateConfig = (doClip = true) => {
		const {colour, outer, inner, text} = $config.get().crosshair;
		const thickness = Math.max(inner, outer);
		
		this.container.style.filter = `drop-shadow(${colour.shadow} 0 0 1px)`;
		
		this.lines.horizontal.style.translate = `0 -${thickness / 2}px`;
		this.lines.vertical.style.translate = `-${thickness / 2}px 0`;
		
		this.lines.horizontal.style.height = this.lines.vertical.style.width = `${thickness}px`;
		
		this.lines.horizontal.style.backgroundColor = this.lines.vertical.style.backgroundColor = colour.fill;
		
		if (text) {
			this.text.style.color = colour.fill;
			
			this.text.style.font = text.font;
			this.text.style.left = `${text.position.x}%`;
			this.text.style.top = `${text.position.y}%`;
			this.text.style.transform = `translate(${text.translate.x}%,${text.translate.y}%) translate(${text.offset.x}px,${text.offset.y}px)`;
			this.text.style.textAlign = text.align;
			this.text.style.lineHeight = text.height;
			
			this.container.append(this.text);
		} else {
			this.text.remove();
		}
		
		if (doClip) {
			this.clip();
		}
	};
	
	$config.ready.then(() => {
		this.updateConfig(false);
	});
}();

// ELEMENT CHANGE LISTENERS

const observer = new function () {
	const onResolutionChange = () => {
		glow.handleSizeChange?.();
	};
	
	const styleObserver = new MutationObserver((() => {
		const properties = ['top', 'left', 'width', 'height', 'scale', 'rotate', 'translate', 'transform-origin'];
		
		let priorStyle;
		
		return () => {
			// mousemove events on video with ctrlKey=true trigger this but have no effect
			if (video.style.cssText === priorStyle) {
				return;
			}
			
			priorStyle = video.style.cssText;
			
			for (const property of properties) {
				containers.background.style[property] = video.style[property];
				containers.foreground.style[property] = video.style[property];
				
				// cinematics doesn't exist for embedded vids
				if (cinematics) {
					cinematics.style[property] = video.style[property];
				}
			}
			
			glow.handleViewChange();
		};
	})());
	
	const videoObserver = new ResizeObserver(() => {
		viewportAngles.set();
		
		glow.handleSizeChange?.();
	});
	
	const viewportObserver = new ResizeObserver(() => {
		viewportAngles.set();
		
		crosshair.clip();
	});
	
	this.start = () => {
		video.addEventListener('resize', onResolutionChange);
		
		styleObserver.observe(video, {attributes: true, attributeFilter: ['style']});
		viewportObserver.observe(viewport);
		videoObserver.observe(video);
		
		glow.handleViewChange();
	};
	
	this.stop = () => {
		video.removeEventListener('resize', onResolutionChange);
		
		styleObserver.disconnect();
		viewportObserver.disconnect();
		videoObserver.disconnect();
	};
}();

// NAVIGATION LISTENERS

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

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

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

// LISTENER ASSIGNMENTS

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

// keyboard state change

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

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

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

QingJ © 2025

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