YouTube Viewfinding

Zoom, rotate & crop YouTube videos

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

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

/* global $Config */

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

// Don't run in non-embed frames (e.g. stream chat frame)
if (window.parent !== window && !isEmbed) {
	return;
}

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

const $config = new $Config(
	'VIEWFIND_TREE',
	(() => {
		const isCSSRule = (() => {
			const wrapper = document.createElement('style');
			const regex = /\s/g;

			return (property, text) => {
				const ruleText = `${property}:${text};`;

				document.head.appendChild(wrapper);
				wrapper.sheet.insertRule(`:not(*){${ruleText}}`);

				const [{style: {cssText}}] = wrapper.sheet.cssRules;

				wrapper.remove();

				return cssText.replaceAll(regex, '') === ruleText.replaceAll(regex, '') || `Must be a valid CSS ${property} rule`;
			};
		})();

		const getHideId = (() => {
			let id = -1;

			return () => ++id;
		})();

		const glowHideId = getHideId();

		return {
			get: (_, configs) => Object.assign(...configs),
			children: [
				{
					label: 'Controls',
					children: [
						{
							label: 'Keybinds',
							descendantPredicate: ([actions, reset, configure]) => {
								const keybinds = [...actions.children.slice(1), reset, configure].map(({children}) => children.filter(({value}) => value !== '').map(({value}) => value));

								for (let i = 0; i < keybinds.length - 1; ++i) {
									for (let j = i + 1; j < keybinds.length; ++j) {
										if (keybinds[i].length === keybinds[j].length && keybinds[i].every((keyA) => keybinds[j].some((keyB) => keyA === keyB))) {
											return 'Another action has this keybind';
										}
									}
								}

								return true;
							},
							get: (_, configs) => ({keys: Object.assign(...configs)}),
							children: (() => {
								const seed = {
									value: '',
									listeners: {
										keydown: (event) => {
											switch (event.key) {
											case 'Enter':
											case 'Escape':
												return;
											}

											event.preventDefault();

											event.target.value = event.code;

											event.target.dispatchEvent(new InputEvent('input'));
										},
									},
								};

								const getKeys = (children) => new Set(children.filter(({value}) => value !== '').map(({value}) => value));

								const getNode = (label, keys, get) => ({
									label,
									seed,
									children: keys.map((value) => ({...seed, value})),
									get,
								});

								return [
									{
										label: 'Actions',
										get: (_, [toggle, ...controls]) => Object.assign(...controls.map(({id, keys}) => ({
											[id]: {
												toggle,
												keys,
											},
										}))),
										children: [
											{
												label: 'Toggle?',
												value: false,
												get: ({value}) => value,
											},
											...[
												['Pan / Zoom', ['KeyZ'], 'pan'],
												['Rotate', ['IntlBackslash'], 'rotate'],
												['Crop', ['KeyZ', 'IntlBackslash'], 'crop'],
											].map(([label, keys, id]) => getNode(label, keys, ({children}) => ({id, keys: getKeys(children)}))),
										],
									},
									getNode('Reset', ['KeyX'], ({children}) => ({reset: {keys: getKeys(children)}})),
									getNode('Configure', ['AltLeft', 'KeyX'], ({children}) => ({config: {keys: getKeys(children)}})),
								];
							})(),
						},
						{
							label: 'Scroll Speeds',
							get: (_, configs) => ({speeds: Object.assign(...configs)}),
							children: [
								{
									label: 'Zoom',
									value: -100,
									get: ({value}) => ({zoom: value / 150000}),
								},
								{
									label: 'Rotate',
									value: -100,
									// 150000 * (5 - 0.8) / 2π ≈ 100000
									get: ({value}) => ({rotate: value / 100000}),
								},
								{
									label: 'Crop',
									value: -100,
									get: ({value}) => ({crop: value / 300000}),
								},
							],
						},
						{
							label: 'Drag Inversions',
							get: (_, configs) => ({multipliers: Object.assign(...configs)}),
							children: [
								['Pan', 'pan'],
								['Rotate', 'rotate'],
								['Crop', 'crop'],
							].map(([label, key, value = false]) => ({
								label,
								value,
								get: ({value}) => ({[key]: value ? -1 : 1}),
							})),
						},
						{
							label: 'Click Movement Allowance (px)',
							value: 2,
							predicate: (value) => value >= 0 || 'Allowance must be positive',
							inputAttributes: {min: 0},
							get: ({value: clickCutoff}) => ({clickCutoff}),
						},
					],
				},
				{
					label: 'Behaviour',
					children: [
						...(() => {
							const typeNode = {
								label: 'Type',
								get: ({value}) => ({type: value}),
							};

							const hiddenNodes = {
								[LIMITS.static]: {
									label: 'Value (%)',
									predicate: (value) => value >= 0 || 'Limit must be positive',
									inputAttributes: {min: 0},
									get: ({value}) => ({custom: value / 100}),
								},
								[LIMITS.fit]: {
									label: 'Glow Allowance (%)',
									predicate: (value) => value >= 0 || 'Allowance must be positive',
									inputAttributes: {min: 0},
									get: ({value}) => ({frame: value / 100}),
								},
							};

							const getNode = (label, key, value, options, ...hidden) => {
								const hideIds = {};
								const children = [{...typeNode, value, options}];

								for (const {id, value} of hidden) {
									const node = {...hiddenNodes[id], value, hideId: getHideId()};

									hideIds[node.hideId] = id;

									children.push(node);
								}

								if (hidden.length > 0) {
									children[0].onUpdate = (value) => {
										const hide = {};

										for (const [id, type] of Object.entries(hideIds)) {
											hide[id] = value !== type;
										}

										return {hide};
									};
								}

								return {
									label,
									get: (_, configs) => ({[key]: Object.assign(...configs)}),
									children,
								};
							};

							return [
								getNode(
									'Zoom In Limit',
									'zoomInLimit',
									LIMITS.static,
									[LIMITS.none, LIMITS.static, LIMITS.fit],
									{id: LIMITS.static, value: 500},
									{id: LIMITS.fit, value: 0},
								),
								getNode(
									'Zoom Out Limit',
									'zoomOutLimit',
									LIMITS.static,
									[LIMITS.none, LIMITS.static, LIMITS.fit],
									{id: LIMITS.static, value: 80},
									{id: LIMITS.fit, value: 300},
								),
								getNode(
									'Pan Limit',
									'panLimit',
									LIMITS.static,
									[LIMITS.none, LIMITS.static, LIMITS.fit],
									{id: LIMITS.static, value: 50},
								),
								getNode(
									'Snap Pan Limit',
									'snapPanLimit',
									LIMITS.fit,
									[LIMITS.none, LIMITS.fit],
								),
							];
						})(),
						{
							label: 'While Viewfinding',
							get: (_, configs) => {
								const {overlayKill, overlayHide, ...config} = Object.assign(...configs);

								return {
									active: {
										overlayRule: overlayKill && [overlayHide ? 'display' : 'pointer-events', 'none'],
										...config,
									},
								};
							},
							children: [
								{
									label: 'Pause Video?',
									value: false,
									get: ({value: pause}) => ({pause}),
								},
								{
									label: 'Hide Glow?',
									value: false,
									get: ({value: hideGlow}) => ({hideGlow}),
									hideId: glowHideId,
								},
								...((hideId) => [
									{
										label: 'Disable Overlay?',
										value: true,
										get: ({value: overlayKill}, configs) => Object.assign({overlayKill}, ...configs),
										onUpdate: (value) => ({hide: {[hideId]: !value}}),
										children: [
											{
												label: 'Hide Overlay?',
												value: false,
												get: ({value: overlayHide}) => ({overlayHide}),
												hideId,
											},
										],
									},
								])(getHideId()),
							],
						},

					],
				},
				{
					label: 'Glow',
					value: true,
					onUpdate: (value) => ({hide: {[glowHideId]: !value}}),
					get: ({value: on}, configs) => {
						if (!on) {
							return {};
						}

						const {turnover, ...config} = Object.assign(...configs);
						const sampleCount = Math.floor(config.fps * turnover);

						// avoid taking more samples than there's space for
						if (sampleCount > config.size) {
							const fps = config.size / turnover;

							return {
								glow: {
									...config,
									sampleCount: config.size,
									interval: 1000 / fps,
									fps,
								},
							};
						}

						return {
							glow: {
								...config,
								interval: 1000 / config.fps,
								sampleCount,
							},
						};
					},
					children: [
						(() => {
							const [seed, getChild] = (() => {
								const options = ['blur', 'brightness', 'contrast', 'drop-shadow', 'grayscale', 'hue-rotate', 'invert', 'opacity', 'saturate', 'sepia'];
								const ids = {};
								const hide = {};

								for (const option of options) {
									ids[option] = getHideId();

									hide[ids[option]] = true;
								}

								const min0Amount = {
									label: 'Amount (%)',
									value: 100,
									predicate: (value) => value >= 0 || 'Amount must be positive',
									inputAttributes: {min: 0},
								};

								const max100Amount = {
									label: 'Amount (%)',
									value: 0,
									predicate: (value) => {
										if (value < 0) {
											return 'Amount must be positive';
										}

										return value <= 100 || 'Amount may not exceed 100%';
									},
									inputAttributes: {min: 0, max: 100},
								};

								const getScaled = (value) => `calc(${value}px/var(${VAR_ZOOM}))`;

								const root = {
									label: 'Function',
									options,
									value: options[0],
									get: ({value}, configs) => {
										const config = Object.assign(...configs);

										switch (value) {
										case options[0]:
											return {
												filter: config.blurScale ? `blur(${config.blur}px)` : `blur(${getScaled(config.blur)})`,
												blur: {
													x: config.blur,
													y: config.blur,
													scale: config.blurScale,
												},
											};

										case options[3]:
											return {
												filter: config.shadowScale ?
													`drop-shadow(${config.shadow} ${config.shadowX}px ${config.shadowY}px ${config.shadowSpread}px)` :
													`drop-shadow(${config.shadow} ${getScaled(config.shadowX)} ${getScaled(config.shadowY)} ${getScaled(config.shadowSpread)})`,
												blur: {
													x: config.shadowSpread + Math.abs(config.shadowX),
													y: config.shadowSpread + Math.abs(config.shadowY),
													scale: config.shadowScale,
												},
											};

										case options[5]:
											return {filter: `hue-rotate(${config.hueRotate}deg)`};
										}

										return {filter: `${value}(${config[value]}%)`};
									},
									onUpdate: (value) => ({hide: {...hide, [ids[value]]: false}}),
								};

								const children = {
									'blur': [
										{
											label: 'Distance (px)',
											value: 0,
											get: ({value}) => ({blur: value}),
											predicate: (value) => value >= 0 || 'Distance must be positive',
											inputAttributes: {min: 0},
											hideId: ids.blur,
										},
										{
											label: 'Scale?',
											value: false,
											get: ({value}) => ({blurScale: value}),
											hideId: ids.blur,
										},
									],
									'brightness': [
										{
											...min0Amount,
											hideId: ids.brightness,
											get: ({value}) => ({brightness: value}),
										},
									],
									'contrast': [
										{
											...min0Amount,
											hideId: ids.contrast,
											get: ({value}) => ({contrast: value}),
										},
									],
									'drop-shadow': [
										{
											label: 'Colour',
											input: 'color',
											value: '#FFFFFF',
											get: ({value}) => ({shadow: value}),
											hideId: ids['drop-shadow'],
										},
										{
											label: 'Horizontal Offset (px)',
											value: 0,
											get: ({value}) => ({shadowX: value}),
											hideId: ids['drop-shadow'],
										},
										{
											label: 'Vertical Offset (px)',
											value: 0,
											get: ({value}) => ({shadowY: value}),
											hideId: ids['drop-shadow'],
										},
										{
											label: 'Spread (px)',
											value: 0,
											predicate: (value) => value >= 0 || 'Spread must be positive',
											inputAttributes: {min: 0},
											get: ({value}) => ({shadowSpread: value}),
											hideId: ids['drop-shadow'],
										},
										{
											label: 'Scale?',
											value: true,
											get: ({value}) => ({shadowScale: value}),
											hideId: ids['drop-shadow'],
										},
									],
									'grayscale': [
										{
											...max100Amount,
											hideId: ids.grayscale,
											get: ({value}) => ({grayscale: value}),
										},
									],
									'hue-rotate': [
										{
											label: 'Angle (deg)',
											value: 0,
											get: ({value}) => ({hueRotate: value}),
											hideId: ids['hue-rotate'],
										},
									],
									'invert': [
										{
											...max100Amount,
											hideId: ids.invert,
											get: ({value}) => ({invert: value}),
										},
									],
									'opacity': [
										{
											...max100Amount,
											value: 100,
											hideId: ids.opacity,
											get: ({value}) => ({opacity: value}),
										},
									],
									'saturate': [
										{
											...min0Amount,
											hideId: ids.saturate,
											get: ({value}) => ({saturate: value}),
										},
									],
									'sepia': [
										{
											...max100Amount,
											hideId: ids.sepia,
											get: ({value}) => ({sepia: value}),
										},
									],
								};

								return [
									{...root, children: Object.values(children).flat()}, (id, ...values) => {
										const replacements = [];

										for (const [i, child] of children[id].entries()) {
											replacements.push({...child, value: values[i]});
										}

										return {
											...root,
											value: id,
											children: Object.values({...children, [id]: replacements}).flat(),
										};
									},
								];
							})();

							return {
								label: 'Filter',
								get: (_, configs) => {
									const scaled = {x: 0, y: 0};
									const unscaled = {x: 0, y: 0};

									let filter = '';

									for (const config of configs) {
										filter += config.filter;

										if ('blur' in config) {
											const target = config.blur.scale ? scaled : unscaled;

											target.x = Math.max(target.x, config.blur.x);
											target.y = Math.max(target.y, config.blur.y);
										}
									}

									return {filter, blur: {scaled, unscaled}};
								},
								children: [
									getChild('saturate', 150),
									getChild('brightness', 150),
									getChild('blur', 25, false),
								],
								seed,
							};
						})(),
						{
							label: 'Update',
							childPredicate: ([{value: fps}, {value: turnover}]) => fps * turnover >= 1 || `${turnover} second turnover cannot be achieved at ${fps} hertz`,
							children: [
								{
									label: 'Frequency (Hz)',
									value: 15,
									predicate: (value) => {
										if (value > 144) {
											return 'Update frequency may not be above 144 hertz';
										}

										return value >= 0 || 'Update frequency must be positive';
									},
									inputAttributes: {min: 0, max: 144},
									get: ({value: fps}) => ({fps}),
								},
								{
									label: 'Turnover Time (s)',
									value: 3,
									predicate: (value) => value >= 0 || 'Turnover time must be positive',
									inputAttributes: {min: 0},
									get: ({value: turnover}) => ({turnover}),
								},
								{
									label: 'Reverse?',
									value: false,
									get: ({value: doFlip}) => ({doFlip}),
								},
							],
						},
						{
							label: 'Size (px)',
							value: 50,
							predicate: (value) => value >= 0 || 'Size must be positive',
							inputAttributes: {min: 0},
							get: ({value}) => ({size: value}),
						},
						{
							label: 'End Point (%)',
							value: 103,
							predicate: (value) => value >= 0 || 'End point must be positive',
							inputAttributes: {min: 0},
							get: ({value}) => ({end: value / 100}),
						},
					].map((node) => ({...node, hideId: glowHideId})),
				},
				{
					label: 'Interfaces',
					children: [
						{
							label: 'Crop',
							get: (_, configs) => ({crop: Object.assign(...configs)}),
							children: [
								{
									label: 'Colours',
									get: (_, configs) => ({colour: Object.assign(...configs)}),
									children: [
										{
											label: 'Fill',
											get: (_, [colour, opacity]) => ({fill: `${colour}${opacity}`}),
											children: [
												{
													label: 'Colour',
													value: '#808080',
													input: 'color',
													get: ({value}) => value,
												},
												{
													label: 'Opacity (%)',
													value: 40,
													predicate: (value) => {
														if (value < 0) {
															return 'Opacity must be positive';
														}

														return value <= 100 || 'Opacity may not exceed 100%';
													},
													inputAttributes: {min: 0, max: 100},
													get: ({value}) => Math.round(255 * value / 100).toString(16),
												},
											],
										},
										{
											label: 'Shadow',
											value: '#000000',
											input: 'color',
											get: ({value: shadow}) => ({shadow}),
										},
										{
											label: 'Border',
											value: '#ffffff',
											input: 'color',
											get: ({value: border}) => ({border}),
										},
									],
								},
								{
									label: 'Handle Size (%)',
									value: 6,
									predicate: (value) => {
										if (value < 0) {
											return 'Size must be positive';
										}

										return value <= 50 || 'Size may not exceed 50%';
									},
									inputAttributes: {min: 0, max: 50},
									get: ({value}) => ({handle: value / 100}),
								},
							],
						},
						{
							label: 'Crosshair',
							get: (value, configs) => ({crosshair: Object.assign(...configs)}),
							children: [
								{
									label: 'Outer Thickness (px)',
									value: 3,
									predicate: (value) => value >= 0 || 'Thickness must be positive',
									inputAttributes: {min: 0},
									get: ({value: outer}) => ({outer}),
								},
								{
									label: 'Inner Thickness (px)',
									value: 1,
									predicate: (value) => value >= 0 || 'Thickness must be positive',
									inputAttributes: {min: 0},
									get: ({value: inner}) => ({inner}),
								},
								{
									label: 'Inner Diameter (px)',
									value: 157,
									predicate: (value) => value >= 0 || 'Diameter must be positive',
									inputAttributes: {min: 0},
									get: ({value: gap}) => ({gap}),
								},
								((hideId) => ({
									label: 'Text',
									value: true,
									onUpdate: (value) => ({hide: {[hideId]: !value}}),
									get: ({value}, configs) => {
										if (!value) {
											return {};
										}

										const {translateX, translateY, ...config} = Object.assign(...configs);

										return {
											text: {
												translate: {
													x: translateX,
													y: translateY,
												},
												...config,
											},
										};
									},
									children: [
										{
											label: 'Font',
											value: '30px "Harlow Solid", cursive',
											predicate: isCSSRule.bind(null, 'font'),
											get: ({value: font}) => ({font}),
										},
										{
											label: 'Position (%)',
											get: (_, configs) => ({position: Object.assign(...configs)}),
											children: ['x', 'y'].map((label) => ({
												label,
												value: 0,
												predicate: (value) => Math.abs(value) <= 50 || 'Position must be on-screen',
												inputAttributes: {min: -50, max: 50},
												get: ({value}) => ({[label]: value + 50}),
											})),
										},
										{
											label: 'Offset (px)',
											get: (_, configs) => ({offset: Object.assign(...configs)}),
											children: [
												{
													label: 'x',
													value: -6,
													get: ({value: x}) => ({x}),
												},
												{
													label: 'y',
													value: -25,
													get: ({value: y}) => ({y}),
												},
											],
										},
										(() => {
											const options = ['Left', 'Center', 'Right'];

											return {
												label: 'Alignment',
												value: options[2],
												options,
												get: ({value}) => ({align: value.toLowerCase(), translateX: options.indexOf(value) * -50}),
											};
										})(),
										(() => {
											const options = ['Top', 'Middle', 'Bottom'];

											return {
												label: 'Baseline',
												value: options[0],
												options,
												get: ({value}) => ({translateY: options.indexOf(value) * -50}),
											};
										})(),
										{
											label: 'Line height (%)',
											value: 90,
											predicate: (value) => value >= 0 || 'Height must be positive',
											inputAttributes: {min: 0},
											get: ({value}) => ({height: value / 100}),
										},
									].map((node) => ({...node, hideId})),
								}))(getHideId()),
								{
									label: 'Colours',
									get: (_, configs) => ({colour: Object.assign(...configs)}),
									children: [
										{
											label: 'Fill',
											value: '#ffffff',
											input: 'color',
											get: ({value: fill}) => ({fill}),
										},
										{
											label: 'Shadow',
											value: '#000000',
											input: 'color',
											get: ({value: shadow}) => ({shadow}),
										},
									],
								},
							],
						},
					],
				},
			],
		};
	})(),
	{
		defaultStyle: {
			headBase: '#c80000',
			headButtonExit: '#000000',
			borderHead: '#ffffff',
			borderTooltip: '#c80000',
			width: Math.min(90, screen.width / 16),
			height: 90,
		},
		outerStyle: {
			zIndex: 10000,
			scrollbarColor: 'initial',
		},
		patches: [
			// removing "Glow Allowance" from pan limits
			({children: [, {children}]}) => {
				// pan
				children[2].children.splice(2, 1);
				// snap pan
				children[3].children.splice(1, 1);
			},
		],
	},
);

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

// STATE

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

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

// other
let stopped = true;
let stopDrag;

const handleVideoChange = () => {
	DimensionCache.id++;

	halfDimensions.video.width = video.clientWidth / 2;
	halfDimensions.video.height = video.clientHeight / 2;

	videoTheta = getTheta(0, 0, video.clientWidth, video.clientHeight);
	videoHypotenuse = Math.sqrt(halfDimensions.video.width * halfDimensions.video.width + halfDimensions.video.height * halfDimensions.video.height);
};

const handleViewportChange = () => {
	DimensionCache.id++;

	isThin = getTheta(0, 0, viewport.clientWidth, viewport.clientHeight) < videoTheta;

	halfDimensions.viewport.width = viewport.clientWidth / 2;
	halfDimensions.viewport.height = viewport.clientHeight / 2;

	viewportRatio = viewport.clientWidth / viewport.clientHeight;
	viewportRatioInverse = 1 / viewportRatio;

	position.constrain();

	glow.handleViewChange(true);
};

// ROTATION HELPERS

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

const getRotatedCorners = (radius, theta) => {
	const angle0 = DEGREES[90] - theta + rotation.value;
	const angle1 = theta + rotation.value - DEGREES[90];

	return [
		{
			x: Math.abs(radius * Math.cos(angle0)),
			y: Math.abs(radius * Math.sin(angle0)),
		},
		{
			x: Math.abs(radius * Math.cos(angle1)),
			y: Math.abs(radius * Math.sin(angle1)),
		},
	];
};

// CSS HELPER

const css = new function () {
	this.has = (name) => document.body.classList.contains(name);
	this.tag = (name, doAdd = true) => document.body.classList[doAdd ? 'add' : 'remove'](name);

	this.getSelector = (...classes) => `body.${classes.join('.')}`;

	const getSheet = () => {
		const element = document.createElement('style');

		document.head.appendChild(element);

		return element.sheet;
	};

	const getRuleString = (selector, ...declarations) => `${selector}{${declarations.map(([property, value]) => `${property}:${value};`).join('')}}`;

	this.add = function (...rule) {
		this.insertRule(getRuleString(...rule));
	}.bind(getSheet());

	this.Toggleable = class {
		static sheet = getSheet();

		static active = [];

		static id = 0;

		static add(rule, id) {
			this.sheet.insertRule(rule, this.active.length);

			this.active.push(id);
		}

		static remove(id) {
			let index = this.active.indexOf(id);

			while (index >= 0) {
				this.sheet.deleteRule(index);

				this.active.splice(index, 1);

				index = this.active.indexOf(id);
			}
		}

		id = this.constructor.id++;

		add(...rule) {
			this.constructor.add(getRuleString(...rule), this.id);
		}

		remove() {
			this.constructor.remove(this.id);
		}
	};
}();

// ACTION MANAGER

const enabler = new function () {
	this.CLASS_ABLE = 'viewfind-action-able';
	this.CLASS_DRAGGING = 'viewfind-action-dragging';

	this.keys = new Set();

	this.didPause = false;
	this.isHidingGlow = false;

	this.setActive = (action) => {
		const {active, keys} = $config.get();

		if (active.hideGlow && Boolean(action) !== this.isHidingGlow) {
			if (action) {
				this.isHidingGlow = true;

				glow.hide();
			} else if (this.isHidingGlow) {
				this.isHidingGlow = false;

				glow.show();
			}
		}

		this.activeAction?.onInactive?.();

		if (action) {
			this.activeAction = action;
			this.toggled = keys[action.CODE].toggle;

			action.onActive?.();

			if (active.pause && !video.paused) {
				video.pause();

				this.didPause = true;
			}

			return;
		}

		if (this.didPause) {
			video.play();

			this.didPause = false;
		}

		this.activeAction = this.toggled = undefined;
	};

	this.handleChange = () => {
		if (stopped || stopDrag || video.ended) {
			return;
		}

		const {keys} = $config.get();

		let activeAction;

		for (const action of Object.values(actions)) {
			if (
				keys[action.CODE].keys.size === 0 || !this.keys.isSupersetOf(keys[action.CODE].keys) || activeAction && ('toggle' in keys[action.CODE] ?
					!('toggle' in keys[activeAction.CODE]) || keys[activeAction.CODE].keys.size >= keys[action.CODE].keys.size :
					!('toggle' in keys[activeAction.CODE]) && keys[activeAction.CODE].keys.size >= keys[action.CODE].keys.size)
			) {
				if ('CLASS_ABLE' in action) {
					css.tag(action.CLASS_ABLE, false);
				}

				continue;
			}

			if (activeAction && 'CLASS_ABLE' in activeAction) {
				css.tag(activeAction.CLASS_ABLE, false);
			}

			activeAction = action;
		}

		if (activeAction === this.activeAction) {
			return;
		}

		if (activeAction) {
			if ('CLASS_ABLE' in activeAction) {
				css.tag(activeAction.CLASS_ABLE);

				css.tag(this.CLASS_ABLE);

				this.setActive(activeAction);

				return;
			}

			this.activeAction?.onInactive?.();

			activeAction.onActive();

			this.activeAction = activeAction;
		}

		css.tag(this.CLASS_ABLE, false);

		this.setActive(false);
	};

	this.stop = () => {
		css.tag(this.CLASS_ABLE, false);

		for (const action of Object.values(actions)) {
			if ('CLASS_ABLE' in action) {
				css.tag(action.CLASS_ABLE, false);
			}
		}

		this.setActive(false);
	};

	this.updateConfig = (() => {
		const rule = new css.Toggleable();
		const selector = `${css.getSelector(this.CLASS_ABLE)} #contentContainer.tp-yt-app-drawer[swipe-open]::after`
			+ `,${css.getSelector(this.CLASS_ABLE)} #movie_player > .html5-video-container ~ :not(.${CLASS_VIEWFINDER})`;

		return () => {
			const {overlayRule} = $config.get().active;

			rule.remove();

			if (overlayRule) {
				rule.add(selector, overlayRule);
			}
		};
	})();

	$config.ready.then(() => {
		this.updateConfig();
	});

	// insertion order decides priority
	css.add(`${css.getSelector(this.CLASS_DRAGGING)} #movie_player`, ['cursor', 'grabbing']);
	css.add(`${css.getSelector(this.CLASS_ABLE)} #movie_player`, ['cursor', 'grab']);
}();

// ELEMENT CONTAINER SETUP

const containers = new function () {
	for (const name of ['background', 'foreground', 'tracker']) {
		this[name] = document.createElement('div');

		this[name].classList.add(CLASS_VIEWFINDER);
	}

	// make an outline of the uncropped video
	css.add(`${css.getSelector(enabler.CLASS_ABLE)} #${this.foreground.id = 'viewfind-outlined'}`, ['outline', '1px solid white']);

	this.background.style.position = this.foreground.style.position = 'absolute';
	this.background.style.pointerEvents = this.foreground.style.pointerEvents = this.tracker.style.pointerEvents = 'none';
	this.tracker.style.height = this.tracker.style.width = '100%';
}();

// MODIFIERS

class Cache {
	targets = [];

	constructor(...targets) {
		for (const source of targets) {
			this.targets.push({source});
		}
	}

	update(target) {
		return target.value !== (target.value = target.source.value);
	}

	isStale() {
		return this.targets.reduce((value, target) => value || this.update(target), false);
	}
}

class ConfigCache extends Cache {
	static id = 0;

	id = this.constructor.id;

	constructor(...targets) {
		super(...targets);
	}

	isStale() {
		if (this.id === (this.id = this.constructor.id)) {
			return super.isStale();
		}

		for (const target of this.targets) {
			target.value = target.source.value;
		}

		return true;
	}
}

class DimensionCache extends ConfigCache {
	static id = 0;
}

const rotation = new function () {
	this.value = DEGREES[90];

	this.reset = () => {
		this.value = DEGREES[90];

		video.style.removeProperty('rotate');
	};

	this.apply = () => {
		// Conversion from anticlockwise rotation from the x-axis to clockwise rotation from the y-axis
		video.style.setProperty('rotate', `${DEGREES[90] - this.value}rad`);

		delete actions.reset.restore;
	};

	// dissimilar from other constrain functions in that no effective limit is applied
	// -1.5π < rotation <= 0.5π
	// 0 <= 0.5π - rotation < 2π
	this.constrain = () => {
		this.value %= DEGREES[360];

		if (this.value > DEGREES[90]) {
			this.value -= DEGREES[360];
		} else if (this.value <= -DEGREES[270]) {
			this.value += DEGREES[360];
		}

		this.apply();
	};
}();

const zoom = new function () {
	this.value = 1;

	const scaleRule = new css.Toggleable();

	this.reset = () => {
		this.value = 1;

		video.style.removeProperty('scale');

		scaleRule.remove();
		scaleRule.add(':root', [VAR_ZOOM, '1']);
	};

	this.apply = () => {
		video.style.setProperty('scale', `${this.value}`);

		scaleRule.remove();
		scaleRule.add(':root', [VAR_ZOOM, `${this.value}`]);

		delete actions.reset.restore;
	};

	const getFit = (corner0, corner1, doSplit = false) => {
		const x = Math.max(corner0.x, corner1.x) / viewport.clientWidth;
		const y = Math.max(corner0.y, corner1.y) / viewport.clientHeight;

		return doSplit ? [0.5 / x, 0.5 / y] : 0.5 / Math.max(x, y);
	};

	this.getFit = (width, height) => getFit(...getRotatedCorners(Math.sqrt(width * width + height * height), getTheta(0, 0, width, height)));
	this.getVideoFit = (doSplit) => getFit(...getRotatedCorners(videoHypotenuse, videoTheta), doSplit);

	this.constrain = (() => {
		const limitGetters = {
			[LIMITS.static]: [({custom}) => custom, ({custom}) => custom],
			[LIMITS.fit]: (() => {
				const getGetter = () => {
					const zoomCache = new Cache(this);
					const rotationCache = new DimensionCache(rotation);
					const configCache = new ConfigCache();

					let updateOnZoom;

					let value;

					return ({frame}, glow) => {
						let fallthrough = rotationCache.isStale();

						if (configCache.isStale()) {
							if (glow) {
								const {scaled} = glow.blur;

								updateOnZoom = frame > 0 && (scaled.x > 0 || scaled.y > 0);
							} else {
								updateOnZoom = false;
							}

							fallthrough = true;
						}

						if (zoomCache.isStale() && updateOnZoom || fallthrough) {
							if (glow) {
								const base = glow.end - 1;
								const {scaled, unscaled} = glow.blur;

								value = this.getFit(
									halfDimensions.video.width + Math.max(0, base * halfDimensions.video.width + Math.max(unscaled.x, scaled.x * this.value)) * frame,
									halfDimensions.video.height + Math.max(0, base * halfDimensions.video.height + Math.max(unscaled.y, scaled.y * this.value)) * frame,
								);
							} else {
								value = this.getVideoFit();
							}
						}

						return value;
					};
				};

				return [getGetter(), getGetter()];
			})(),
		};

		return () => {
			const {zoomOutLimit, zoomInLimit, glow} = $config.get();

			if (zoomOutLimit.type !== 'None') {
				this.value = Math.max(limitGetters[zoomOutLimit.type][0](zoomOutLimit, glow), this.value);
			}

			if (zoomInLimit.type !== 'None') {
				this.value = Math.min(limitGetters[zoomInLimit.type][1](zoomInLimit, glow, 1), this.value);
			}

			this.apply();
		};
	})();
}();

const position = new function () {
	this.x = this.y = 0;

	this.getValues = () => ({x: this.x, y: this.y});

	this.reset = () => {
		this.x = this.y = 0;

		video.style.removeProperty('translate');
	};

	this.apply = () => {
		video.style.setProperty('transform-origin', `${(0.5 + this.x) * 100}% ${(0.5 - this.y) * 100}%`);
		video.style.setProperty('translate', `${-this.x * 100}% ${this.y * 100}%`);

		delete actions.reset.restore;
	};

	this.constrain = (() => {
		// logarithmic progress from "low" to infinity
		const getProgress = (low, target) => 1 - low / target;

		const getProgressed = ({x: fromX, y: fromY, z: lowZ}, {x: toX, y: toY}, targetZ) => {
			const p = getProgress(lowZ, targetZ);

			return {x: p * (toX - fromX) + fromX, y: p * (toY - fromY) + fromY};
		};

		// y = mx + c
		const getLineY = ({m, c}, x = this.x) => m * x + c;
		// x = (y - c) / m
		const getLineX = ({m, c}, y = this.y) => (y - c) / m;

		const getM = (from, to) => (to.y - from.y) / (to.x - from.x);
		const getLine = (m, {x, y}) => ({c: y - m * x, m});
		const getFlipped = ({x, y}) => ({x: -x, y: -y});

		const correctY = (line, left, right) => {
			if (this.x >= left.x && this.x <= right.x) {
				this.y = getLineY(line, this.x);

				return true;
			}
		};

		const correctX = (line, bottom, top) => {
			if (this.y >= bottom.y && this.y <= top.y) {
				this.x = getLineX(line, this.y);

				return true;
			}
		};

		const isAbove = ({m, c}, {x, y} = this) => m * x + c < y;
		const isRight = ({m, c}, {x, y} = this) => (y - c) / m < x;

		const apply2DFrame = (points, lines) => {
			const {x, y} = this;

			if (Math.abs(lines.right.c) === Infinity) {
				this.x = Math.min(points.topRight.x, Math.max(points.topLeft.x, this.x));
			} else if (isRight(lines.right)) {
				if (correctX(lines.right, points.bottomRight, points.topRight)) {
					return;
				}
			} else if (!isRight(lines.left)) {
				if (correctX(lines.left, points.bottomLeft, points.topLeft)) {
					return;
				}
			}

			if (isAbove(lines.top)) {
				if (correctY(lines.top, points.topLeft, points.topRight)) {
					return;
				}
			} else if (!isAbove(lines.bottom)) {
				if (correctY(lines.bottom, points.bottomLeft, points.bottomRight)) {
					return;
				}
			}

			if (x <= points.bottomLeft.x && y <= points.bottomLeft.y) {
				this.x = points.bottomLeft.x;
				this.y = points.bottomLeft.y;
			} else if (x >= points.bottomRight.x && y <= points.bottomRight.y) {
				this.x = points.bottomRight.x;
				this.y = points.bottomRight.y;
			} else if (x <= points.topLeft.x && y >= points.topLeft.y) {
				this.x = points.topLeft.x;
				this.y = points.topLeft.y;
			} else if (x >= points.topRight.x && y >= points.topRight.y) {
				this.x = points.topRight.x;
				this.y = points.topRight.y;
			}
		};

		const apply1DSideFrame = {
			x: (line) => {
				this.x = Math.max(-line.x, Math.min(line.x, this.x));

				this.y = getLineY(line);
			},
			y: (line) => {
				this.y = Math.max(-line.y, Math.min(line.y, this.y));

				this.x = getLineX(line);
			},
		};

		const swap = (array, i0, i1) => {
			const temp = array[i0];

			array[i0] = array[i1];
			array[i1] = temp;
		};

		const getBoundApplyFrame = (() => {
			const getBound = (first, second, isTopLeft) => {
				if (zoom.value <= first.z) {
					return false;
				}

				if (zoom.value >= second.z) {
					const progress = zoom.value / second.z;

					const x = isTopLeft ?
						-0.5 - (-0.5 - second.x) / progress :
						0.5 - (0.5 - second.x) / progress;

					return {
						x,
						y: 0.5 - (0.5 - second.y) / progress,
					};
				}

				return {
					...getProgressed(first, second.vpEnd, zoom.value),
					axis: second.vpEnd.axis,
					m: second.y / second.x,
					c: 0,
				};
			};

			const getFrame = (point0, point1) => {
				const points = {};
				const lines = {};

				const flipped0 = getFlipped(point0);
				const flipped1 = getFlipped(point1);

				const m0 = getM(point0, point1);
				const m1 = getM(flipped0, point1);

				lines.top = getLine(m0, point0);
				lines.bottom = getLine(m0, flipped0);

				lines.left = getLine(m1, point0);
				lines.right = getLine(m1, flipped0);

				points.topLeft = point0;
				points.topRight = point1;
				points.bottomLeft = flipped1;
				points.bottomRight = flipped0;

				if (video.clientWidth < video.clientHeight) {
					if (getLineX(lines.right, 0) < getLineX(lines.left, 0)) {
						swap(lines, 'right', 'left');

						swap(points, 'bottomLeft', 'bottomRight');
						swap(points, 'topLeft', 'topRight');
					}
				} else {
					if (lines.top.c < lines.bottom.c) {
						swap(lines, 'top', 'bottom');

						swap(points, 'topLeft', 'bottomLeft');
						swap(points, 'topRight', 'bottomRight');
					}
				}

				return [points, lines];
			};

			return (first0, second0, first1, second1) => {
				const point0 = getBound(first0, second0, true);
				const point1 = getBound(first1, second1, false);

				if (!point0 && !point1) {
					return () => {
						this.x = this.y = 0;
					};
				}

				if (!point0 || !point1 || point0.axis && point0.axis === point1.axis) {
					// todo choose the longer line?
					const point = point0 || point1;
					const {axis} = point;

					point.axis ??= Math.abs(point.x) > Math.abs(point.y) ? 'x' : 'y';

					if (point[point.axis] < 0) {
						point.x = -point.x;
						point.y = -point.y;
					}

					if (!axis) {
						point.m = point.y / point.x;

						point.c = 0;
					}

					return apply1DSideFrame[point.axis].bind(null, point);
				}

				return apply2DFrame.bind(null, ...getFrame(point0, point1));
			};
		})();

		const snapZoom = (() => {
			const getDirected = (first, second, flipX, flipY) => {
				const line0 = [first, {}];
				const line1 = [{z: second.z}, {}];

				if (flipX) {
					line0[1].x = -second.vpEnd.x;
					line1[0].x = -second.x;
					line1[1].x = -0.5;
				} else {
					line0[1].x = second.vpEnd.x;
					line1[0].x = second.x;
					line1[1].x = 0.5;
				}

				if (flipY) {
					line0[1].y = -second.vpEnd.y;
					line1[0].y = -second.y;
					line1[1].y = -0.5;
				} else {
					line0[1].y = second.vpEnd.y;
					line1[0].y = second.y;
					line1[1].y = 0.5;
				}

				return [line0, line1];
			};

			// https://math.stackexchange.com/questions/2223691/intersect-2-lines-at-the-same-ratio-through-a-point
			const getIntersectProgress = ({x, y}, [{x: g, y: e}, {x: f, y: d}], [{x: k, y: i}, {x: j, y: h}], doFlip) => {
				const a = d * j - d * k - j * e + e * k - h * f + h * g + i * f - i * g;
				const b = d * k - d * x - e * k + e * x + j * e - k * e - j * y + k * y - h * g + h * x + i * g - i * x - f * i + g * i + f * y - g * y;
				const c = k * e - e * x - k * y - g * i + i * x + g * y;

				return (doFlip ? -b - Math.sqrt(b * b - 4 * a * c) : -b + Math.sqrt(b * b - 4 * a * c)) / (2 * a);
			};

			const getLineFromPoints = (from, to) => getLine(getM(from, to), from);

			// line with progressed start point
			const getProgressedLine = (line, {z}) => [getProgressed(...line, z), line[1]];

			return (first0, _second0, first1, second1) => {
				const second0 = {..._second0, x: -_second0.x, vpEnd: {..._second0.vpEnd, x: -_second0.vpEnd.x}};

				const absPosition = {x: Math.abs(this.x), y: Math.abs(this.y)};

				const getPairings = (flipX0, flipY0, flipX1, flipY1) => {
					const [lineFirst0, lineSecond0] = getDirected(first0, second0, flipX0, flipY0);
					const [lineFirst1, lineSecond1] = getDirected(first1, second1, flipX1, flipY1);

					// array structure is:
					// start zoom for both lines
					// 0 line start and its infinite zoom point
					// 1 line start and its infinite zoom point

					return [
						first0.z >= first1.z ?
							[first0.z, lineFirst0, getProgressedLine(lineFirst1, first0)] :
							[first1.z, getProgressedLine(lineFirst0, first1), lineFirst1],

						...second0.z >= second1.z ?
							[
								[second1.z, getProgressedLine(lineFirst0, second1), lineSecond1],
								[second0.z, lineSecond0, getProgressedLine(lineSecond1, second0)],
							] :
							[
								[second0.z, lineSecond0, getProgressedLine(lineFirst1, second0)],
								[second1.z, getProgressedLine(lineSecond0, second1), lineSecond1],
							],
					];
				};

				const [pair0, pair1, pair2, doFlip = false] = (() => {
					if (this.x >= 0 !== this.y >= 0) {
						return isAbove(getLineFromPoints(second0, {x: 0.5, y: 0.5}), absPosition) ?
							[...getPairings(false, false, true, false), true] :
							getPairings(false, false, false, true);
					}

					return isAbove(getLineFromPoints(second1, {x: 0.5, y: 0.5}), absPosition) ?
						getPairings(true, false, false, false) :
						[...getPairings(false, true, false, false), true];
				})();

				const applyZoomPairSecond = ([z, ...pair], maxP = 1) => {
					const p = getIntersectProgress(absPosition, ...pair, doFlip);

					if (p >= 0 && p <= maxP) {
						// I don't think the >= 1 check is necessary but best be safe
						zoom.value = p >= 1 ? Number.MAX_SAFE_INTEGER : z / (1 - p);

						return true;
					}

					return false;
				};

				if (
					applyZoomPairSecond(pair2)
					|| applyZoomPairSecond(pair1, getProgress(pair1[0], pair2[0]))
					|| applyZoomPairSecond(pair0, getProgress(pair0[0], pair1[0]))
				) {
					return;
				}

				zoom.value = pair0[0];
			};
		})();

		const getZoomPoints = (() => {
			const getPoints = (fitZoom, doFlip) => {
				const getGenericRotated = (x, y, angle) => {
					const radius = Math.sqrt(x * x + y * y);
					const pointTheta = getTheta(0, 0, x, y) + angle;

					return {
						x: radius * Math.cos(pointTheta),
						y: radius * Math.sin(pointTheta),
					};
				};

				const getRotated = (xRaw, yRaw) => {
					// Multiplying by video dimensions to have the axes' scales match the video's
					// Using midPoint's raw values would only work if points moved elliptically around the centre of rotation
					const rotated = getGenericRotated(xRaw * video.clientWidth, yRaw * video.clientHeight, (DEGREES[90] - rotation.value) % DEGREES[180]);

					rotated.x /= video.clientWidth;
					rotated.y /= video.clientHeight;

					return rotated;
				};

				return [
					{...getRotated(halfDimensions.viewport.width / video.clientWidth / fitZoom[0], 0), axis: doFlip ? 'y' : 'x'},
					{...getRotated(0, halfDimensions.viewport.height / video.clientHeight / fitZoom[1]), axis: doFlip ? 'x' : 'y'},
				];
			};

			const getIntersection = (line, corner, middle) => {
				const getIntersection = (line0, line1) => {
					const a0 = line0[0].y - line0[1].y;
					const b0 = line0[1].x - line0[0].x;
					const c0 = line0[1].x * line0[0].y - line0[0].x * line0[1].y;

					const a1 = line1[0].y - line1[1].y;
					const b1 = line1[1].x - line1[0].x;
					const c1 = line1[1].x * line1[0].y - line1[0].x * line1[1].y;

					const d = a0 * b1 - b0 * a1;

					return {
						x: (c0 * b1 - b0 * c1) / d,
						y: (a0 * c1 - c0 * a1) / d,
					};
				};

				const {x, y} = getIntersection([{x: 0, y: 0}, middle], [line, corner]);
				const progress = isThin ? (y - line.y) / (corner.y - line.y) : (x - line.x) / (corner.x - line.x);

				return {x, y, z: line.z / (1 - progress), c: line.y};
			};

			const getIntersect = (yIntersect, corner, right, top) => {
				const point0 = getIntersection(yIntersect, corner, right);
				const point1 = getIntersection(yIntersect, corner, top);

				const [point, vpEnd] = point0.z > point1.z ? [point0, {...right}] : [point1, {...top}];

				if (Math.sign(point[vpEnd.axis]) !== Math.sign(vpEnd[vpEnd.axis])) {
					vpEnd.x = -vpEnd.x;
					vpEnd.y = -vpEnd.y;
				}

				return {...point, vpEnd};
			};

			// the angle from 0,0 to the center of the video edge angled towards the viewport's upper-right corner
			const getQuadrantAngle = (isEvenQuadrant) => {
				const angle = (rotation.value + DEGREES[360]) % DEGREES[90];

				return isEvenQuadrant ? angle : DEGREES[90] - angle;
			};

			return () => {
				const isEvenQuadrant = (Math.floor(rotation.value / DEGREES[90]) + 3) % 2 === 0;
				const quadrantAngle = getQuadrantAngle(isEvenQuadrant);

				const progress = quadrantAngle / DEGREES[90] * -2 + 1;
				const progressAngles = {
					base: Math.atan(progress * viewportRatio),
					side: Math.atan(progress * viewportRatioInverse),
				};
				const progressCosines = {
					base: Math.cos(progressAngles.base),
					side: Math.cos(progressAngles.side),
				};

				const fitZoom = zoom.getVideoFit(true);
				const points = getPoints(fitZoom, quadrantAngle >= DEGREES[45]);

				const sideIntersection = getIntersect(
					((cornerAngle) => ({
						x: 0,
						y: (halfDimensions.video.height - halfDimensions.video.width * Math.tan(cornerAngle)) / video.clientHeight,
						z: halfDimensions.viewport.width / (progressCosines.side * Math.abs(halfDimensions.video.width / Math.cos(cornerAngle))),
					}))(quadrantAngle + progressAngles.side),
					isEvenQuadrant ? {x: -0.5, y: 0.5} : {x: 0.5, y: 0.5},
					...points,
				);

				const baseIntersection = getIntersect(
					((cornerAngle) => ({
						x: 0,
						y: (halfDimensions.video.height - halfDimensions.video.width * Math.tan(cornerAngle)) / video.clientHeight,
						z: halfDimensions.viewport.height / (progressCosines.base * Math.abs(halfDimensions.video.width / Math.cos(cornerAngle))),
					}))(DEGREES[90] - quadrantAngle - progressAngles.base),
					isEvenQuadrant ? {x: 0.5, y: 0.5} : {x: -0.5, y: 0.5},
					...points,
				);

				const [originSide, originBase] = fitZoom.map((z) => ({x: 0, y: 0, z}));

				return isEvenQuadrant ?
					[...[originSide, sideIntersection], ...[originBase, baseIntersection]] :
					[...[originBase, baseIntersection], ...[originSide, sideIntersection]];
			};
		})();

		let zoomPoints;

		const getEnsureZoomPoints = (() => {
			const updateLog = [];
			let count = 0;

			return () => {
				const zoomPointCache = new DimensionCache(rotation);
				const callbackCache = new Cache(zoom);
				const id = count++;

				return () => {
					if (zoomPointCache.isStale()) {
						updateLog.length = 0;

						zoomPoints = getZoomPoints();
					}

					if (callbackCache.isStale() || !updateLog[id]) {
						updateLog[id] = true;

						return true;
					}

					return false;
				};
			};
		})();

		const handlers = {
			[LIMITS.static]: ({custom: ratio}) => {
				const bound = 0.5 + (ratio - 0.5) / zoom.value;

				this.x = Math.max(-bound, Math.min(bound, this.x));
				this.y = Math.max(-bound, Math.min(bound, this.y));
			},
			[LIMITS.fit]: (() => {
				let boundApplyFrame;

				const ensure = getEnsureZoomPoints();

				return () => {
					if (ensure()) {
						boundApplyFrame = getBoundApplyFrame(...zoomPoints);
					}

					boundApplyFrame();
				};
			})(),
		};

		const snapHandlers = {
			[LIMITS.fit]: (() => {
				const ensure = getEnsureZoomPoints();

				return () => {
					ensure();

					snapZoom(...zoomPoints);

					zoom.constrain();
				};
			})(),
		};

		return (doZoom = false) => {
			const {panLimit, snapPanLimit} = $config.get();

			if (doZoom) {
				snapHandlers[snapPanLimit.type]?.();
			}

			handlers[panLimit.type]?.(panLimit);

			this.apply();
		};
	})();
}();

const crop = new function () {
	this.top = this.right = this.bottom = this.left = 0;

	this.getValues = () => ({top: this.top, right: this.right, bottom: this.bottom, left: this.left});

	this.reveal = () => {
		this.top = this.right = this.bottom = this.left = 0;

		rule.remove();
	};

	this.reset = () => {
		this.reveal();

		actions.crop.reset();
	};

	const rule = new css.Toggleable();

	this.apply = () => {
		rule.remove();
		rule.add(
			`${SELECTOR_VIDEO}:not(.${this.CLASS_ABLE} *)`,
			['clip-path', `inset(${this.top * 100}% ${this.right * 100}% ${this.bottom * 100}% ${this.left * 100}%)`],
		);

		delete actions.reset.restore;

		glow.handleViewChange();
		glow.reset();
	};

	this.getDimensions = (width = video.clientWidth, height = video.clientHeight) => [
		width * (1 - this.left - this.right),
		height * (1 - this.top - this.bottom),
	];
}();

// FUNCTIONALITY

const glow = (() => {
	const videoCanvas = new OffscreenCanvas(0, 0);
	const videoCtx = videoCanvas.getContext('2d', {alpha: false});

	const glowCanvas = document.createElement('canvas');
	const glowCtx = glowCanvas.getContext('2d', {alpha: false});

	glowCanvas.style.setProperty('position', 'absolute');

	class Sector {
		canvas = new OffscreenCanvas(0, 0);
		ctx = this.canvas.getContext('2d', {alpha: false});

		update(doFill) {
			if (doFill) {
				this.fill();
			} else {
				this.shift();
				this.take();
			}

			this.giveEdge();

			if (this.hasCorners) {
				this.giveCorners();
			}
		}
	}

	class Side extends Sector {
		setDimensions(doShiftRight, sWidth, sHeight, sx, sy, dx, dy, dWidth, dHeight) {
			this.canvas.width = sWidth;
			this.canvas.height = sHeight;

			this.shift = this.ctx.drawImage.bind(this.ctx, this.canvas, doShiftRight ? 1 : -1, 0);

			this.fill = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, 1, sHeight, 0, 0, sWidth, sHeight);
			this.take = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, 1, sHeight, doShiftRight ? 0 : sWidth - 1, 0, 1, sHeight);

			this.giveEdge = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);

			if (dy === 0) {
				this.hasCorners = false;

				return;
			}

			this.hasCorners = true;

			const giveCorner0 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, 1, dx, 0, dWidth, dy);
			const giveCorner1 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, sHeight - 1, sWidth, 1, dx, dy + dHeight, dWidth, dy);

			this.giveCorners = () => {
				giveCorner0();
				giveCorner1();
			};
		}
	}

	class Base extends Sector {
		setDimensions(doShiftDown, sWidth, sHeight, sx, sy, dx, dy, dWidth, dHeight) {
			this.canvas.width = sWidth;
			this.canvas.height = sHeight;

			this.shift = this.ctx.drawImage.bind(this.ctx, this.canvas, 0, doShiftDown ? 1 : -1);

			this.fill = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, sWidth, 1, 0, 0, sWidth, sHeight);
			this.take = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, sWidth, 1, 0, doShiftDown ? 0 : sHeight - 1, sWidth, 1);

			this.giveEdge = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);

			if (dx === 0) {
				this.hasCorners = false;

				return;
			}

			this.hasCorners = true;

			const giveCorner0 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, 1, sHeight, 0, dy, dx, dHeight);
			const giveCorner1 = glowCtx.drawImage.bind(glowCtx, this.canvas, sWidth - 1, 0, 1, sHeight, dx + dWidth, dy, dx, dHeight);

			this.giveCorners = () => {
				giveCorner0();
				giveCorner1();
			};
		}

		setClipPath(points) {
			this.clipPath = new Path2D();

			this.clipPath.moveTo(...points[0]);
			this.clipPath.lineTo(...points[1]);
			this.clipPath.lineTo(...points[2]);
			this.clipPath.closePath();
		}

		update(doFill) {
			glowCtx.save();

			glowCtx.clip(this.clipPath);

			super.update(doFill);

			glowCtx.restore();
		}
	}

	const components = {
		left: new Side(),
		right: new Side(),
		top: new Base(),
		bottom: new Base(),
	};

	const setComponentDimensions = (sampleCount, size, isInset, doFlip) => {
		const [croppedWidth, croppedHeight] = crop.getDimensions();
		const halfCanvas = {x: Math.ceil(glowCanvas.width / 2), y: Math.ceil(glowCanvas.height / 2)};
		const halfVideo = {x: croppedWidth / 2, y: croppedHeight / 2};
		const dWidth = Math.ceil(Math.min(halfVideo.x, size));
		const dHeight = Math.ceil(Math.min(halfVideo.y, size));
		const [dWidthScale, dHeightScale, sideWidth, sideHeight] = isInset ?
			[0, 0, videoCanvas.width / croppedWidth * glowCanvas.width, videoCanvas.height / croppedHeight * glowCanvas.height] :
			[halfCanvas.x - halfVideo.x, halfCanvas.y - halfVideo.y, croppedWidth, croppedHeight];

		components.left.setDimensions(!doFlip, sampleCount, videoCanvas.height, 0, 0, 0, dHeightScale, dWidth, sideHeight);

		components.right.setDimensions(doFlip, sampleCount, videoCanvas.height, videoCanvas.width - 1, 0, glowCanvas.width - dWidth, dHeightScale, dWidth, sideHeight);

		components.top.setDimensions(!doFlip, videoCanvas.width, sampleCount, 0, 0, dWidthScale, 0, sideWidth, dHeight);
		components.top.setClipPath([[0, 0], [halfCanvas.x, halfCanvas.y], [glowCanvas.width, 0]]);

		components.bottom.setDimensions(doFlip, videoCanvas.width, sampleCount, 0, videoCanvas.height - 1, dWidthScale, glowCanvas.height - dHeight, sideWidth, dHeight);
		components.bottom.setClipPath([[0, glowCanvas.height], [halfCanvas.x, halfCanvas.y], [glowCanvas.width, glowCanvas.height]]);
	};

	class Instance {
		constructor() {
			const {filter, sampleCount, size, end, doFlip} = $config.get().glow;

			// Setup canvases

			glowCanvas.style.setProperty('filter', filter);

			[glowCanvas.width, glowCanvas.height] = crop.getDimensions().map((dimension) => dimension * end);

			glowCanvas.style.setProperty('left', `${crop.left * 100 + (1 - end) * (1 - crop.left - crop.right) * 50}%`);
			glowCanvas.style.setProperty('top', `${crop.top * 100 + (1 - end) * (1 - crop.top - crop.bottom) * 50}%`);

			[videoCanvas.width, videoCanvas.height] = crop.getDimensions(video.videoWidth, video.videoHeight);

			setComponentDimensions(sampleCount, size, end <= 1, doFlip);

			this.update(true);
		}

		update(doFill = false) {
			videoCtx.drawImage(
				video,
				crop.left * video.videoWidth,
				crop.top * video.videoHeight,
				video.videoWidth * (1 - crop.left - crop.right),
				video.videoHeight * (1 - crop.top - crop.bottom),
				0,
				0,
				videoCanvas.width,
				videoCanvas.height,
			);

			components.left.update(doFill);
			components.right.update(doFill);
			components.top.update(doFill);
			components.bottom.update(doFill);
		}
	}

	return new function () {
		const container = document.createElement('div');

		container.style.display = 'none';

		container.appendChild(glowCanvas);
		containers.background.appendChild(container);

		this.isHidden = false;

		let instance, startCopyLoop, stopCopyLoop;

		const play = () => {
			if (!video.paused && !this.isHidden && !enabler.isHidingGlow) {
				startCopyLoop?.();
			}
		};

		const fill = () => {
			if (!this.isHidden) {
				instance.update(true);
			}
		};

		const handleVisibilityChange = () => {
			if (document.hidden) {
				stopCopyLoop();
			} else {
				play();
			}
		};

		this.handleSizeChange = () => {
			instance = new Instance();
		};

		// set up pausing if glow isn't visible
		this.handleViewChange = (() => {
			const cache = new Cache(rotation, zoom);

			let corners;

			return (doForce = false) => {
				if (doForce || cache.isStale()) {
					corners = getRotatedCorners(halfDimensions.viewport.width / zoom.value, halfDimensions.viewport.height / zoom.value);
				}

				const videoX = position.x * video.clientWidth;
				const videoY = position.y * video.clientHeight;

				for (const corner of corners) {
					if (
						// unpause if the viewport extends more than 1 pixel beyond a video edge
						videoX + corner.x > (0.5 - crop.right) * video.clientWidth + 1
						|| videoX - corner.x < (crop.left - 0.5) * video.clientWidth - 1
						|| videoY + corner.y > (0.5 - crop.top) * video.clientHeight + 1
						|| videoY - corner.y < (crop.bottom - 0.5) * video.clientHeight - 1
					) {
						// fill if newly visible
						if (this.isHidden) {
							instance?.update(true);
						}

						this.isHidden = false;

						glowCanvas.style.removeProperty('visibility');

						play();

						return;
					}
				}

				this.isHidden = true;

				glowCanvas.style.visibility = 'hidden';

				stopCopyLoop?.();
			};
		})();

		const loop = {};

		this.start = () => {
			const config = $config.get().glow;

			if (!config) {
				return;
			}

			if (!enabler.isHidingGlow) {
				container.style.removeProperty('display');
			}

			// todo handle this?
			if (crop.left + crop.right >= 1 || crop.top + crop.bottom >= 1) {
				return;
			}

			let loopId = -1;

			if (loop.interval !== config.interval || loop.fps !== config.fps) {
				loop.interval = config.interval;
				loop.fps = config.fps;
				loop.wasSlow = false;
				loop.throttleCount = 0;
			}

			stopCopyLoop = () => ++loopId;

			instance = new Instance();

			startCopyLoop = async () => {
				const id = ++loopId;

				await new Promise((resolve) => {
					window.setTimeout(resolve, config.interval);
				});

				while (id === loopId) {
					const startTime = Date.now();

					instance.update();

					const delay = loop.interval - (Date.now() - startTime);

					if (delay <= 0) {
						if (loop.wasSlow) {
							loop.interval = 1000 / (loop.fps - ++loop.throttleCount);
						}

						loop.wasSlow = !loop.wasSlow;

						continue;
					}

					if (delay > 2 && loop.throttleCount > 0) {
						console.warn(`[${GM.info.script.name}] Glow update frequency reduced from ${loop.fps} hertz to ${loop.fps - loop.throttleCount} hertz due to poor performance.`);

						loop.fps -= loop.throttleCount;

						loop.throttleCount = 0;
					}

					loop.wasSlow = false;

					await new Promise((resolve) => {
						window.setTimeout(resolve, delay);
					});
				}
			};

			play();

			video.addEventListener('pause', stopCopyLoop);
			video.addEventListener('play', play);
			video.addEventListener('seeked', fill);

			document.addEventListener('visibilitychange', handleVisibilityChange);
		};

		const priorCrop = {};

		this.hide = () => {
			Object.assign(priorCrop, crop);

			stopCopyLoop?.();

			container.style.display = 'none';
		};

		this.show = () => {
			if (Object.entries(priorCrop).some(([edge, value]) => crop[edge] !== value)) {
				this.reset();
			} else {
				play();
			}

			container.style.removeProperty('display');
		};

		this.stop = () => {
			this.hide();

			video.removeEventListener('pause', stopCopyLoop);
			video.removeEventListener('play', play);
			video.removeEventListener('seeked', fill);

			document.removeEventListener('visibilitychange', handleVisibilityChange);

			startCopyLoop = undefined;
			stopCopyLoop = undefined;
		};

		this.reset = () => {
			this.stop();

			this.start();
		};
	}();
})();

const peek = (stop = false) => {
	const prior = {
		zoom: zoom.value,
		rotation: rotation.value,
		crop: crop.getValues(),
		position: position.getValues(),
	};

	position.reset();
	rotation.reset();
	zoom.reset();
	crop.reset();

	glow[stop ? 'stop' : 'reset']();

	return () => {
		zoom.value = prior.zoom;
		rotation.value = prior.rotation;
		Object.assign(position, prior.position);
		Object.assign(crop, prior.crop);

		actions.crop.set(prior.crop);

		position.apply();
		rotation.apply();
		zoom.apply();
		crop.apply();
	};
};

const actions = (() => {
	const drag = (event, clickCallback, moveCallback, target = video) => new Promise((resolve) => {
		event.stopImmediatePropagation();
		event.preventDefault();

		// window blur events don't fire if devtools is open
		stopDrag?.();

		target.setPointerCapture(event.pointerId);

		css.tag(enabler.CLASS_DRAGGING);

		const cancel = (event) => {
			event.stopImmediatePropagation();
			event.preventDefault();
		};

		document.addEventListener('click', cancel, true);
		document.addEventListener('dblclick', cancel, true);

		const clickDisallowListener = ({clientX, clientY}) => {
			const {clickCutoff} = $config.get();
			const distance = Math.abs(event.clientX - clientX) + Math.abs(event.clientY - clientY);

			if (distance >= clickCutoff) {
				target.removeEventListener('pointermove', clickDisallowListener);
				target.removeEventListener('pointerup', clickCallback);
			}
		};

		if (clickCallback) {
			target.addEventListener('pointermove', clickDisallowListener);
			target.addEventListener('pointerup', clickCallback, {once: true});
		}

		target.addEventListener('pointermove', moveCallback);

		stopDrag = () => {
			css.tag(enabler.CLASS_DRAGGING, false);

			target.removeEventListener('pointermove', moveCallback);

			if (clickCallback) {
				target.removeEventListener('pointermove', clickDisallowListener);
				target.removeEventListener('pointerup', clickCallback);
			}

			// delay removing listeners for events that happen after pointerup
			window.setTimeout(() => {
				document.removeEventListener('dblclick', cancel, true);
				document.removeEventListener('click', cancel, true);
			}, 0);

			window.removeEventListener('blur', stopDrag);
			target.removeEventListener('pointerup', stopDrag);

			target.releasePointerCapture(event.pointerId);

			stopDrag = undefined;

			enabler.handleChange();

			resolve();
		};

		window.addEventListener('blur', stopDrag);
		target.addEventListener('pointerup', stopDrag);
	});

	const getOnScroll = (() => {
		// https://stackoverflow.com/a/30134826
		const multipliers = [1, 40, 800];

		return (callback) => (event) => {
			event.stopImmediatePropagation();
			event.preventDefault();

			if (event.deltaY !== 0) {
				callback(event.deltaY * multipliers[event.deltaMode]);
			}
		};
	})();

	const addListeners = ({onMouseDown, onRightClick, onScroll}, doAdd = true) => {
		const property = `${doAdd ? 'add' : 'remove'}EventListener`;

		altTarget[property]('pointerdown', onMouseDown);
		altTarget[property]('contextmenu', onRightClick, true);
		altTarget[property]('wheel', onScroll);
	};

	return {
		crop: new function () {
			let top = 0, right = 0, bottom = 0, left = 0, handle;

			const values = {};

			Object.defineProperty(values, 'top', {get: () => top, set: (value) => top = value});
			Object.defineProperty(values, 'right', {get: () => right, set: (value) => right = value});
			Object.defineProperty(values, 'bottom', {get: () => bottom, set: (value) => bottom = value});
			Object.defineProperty(values, 'left', {get: () => left, set: (value) => left = value});

			class Button {
				// allowance for rounding errors
				static ALLOWANCE_HANDLE = 0.0001;

				static CLASS_HANDLE = 'viewfind-crop-handle';
				static CLASS_EDGES = {
					left: 'viewfind-crop-left',
					top: 'viewfind-crop-top',
					right: 'viewfind-crop-right',
					bottom: 'viewfind-crop-bottom',
				};

				static OPPOSITES = {
					left: 'right',
					right: 'left',

					top: 'bottom',
					bottom: 'top',
				};

				callbacks = [];

				element = document.createElement('div');

				constructor(...edges) {
					this.edges = edges;

					this.isHandle = true;

					this.element.style.position = 'absolute';
					this.element.style.pointerEvents = 'all';

					for (const edge of edges) {
						this.element.style[edge] = '0';

						this.element.classList.add(Button.CLASS_EDGES[edge]);

						this.element.style.setProperty(`border-${Button.OPPOSITES[edge]}-width`, '1px');
					}

					this.element.addEventListener('contextmenu', (event) => {
						event.stopPropagation();
						event.preventDefault();

						this.reset(false);
					});

					this.element.addEventListener('pointerdown', (() => {
						const clickListener = ({offsetX, offsetY, target}) => {
							this.set({
								width: (this.edges.includes('left') ? offsetX : target.clientWidth - offsetX) / video.clientWidth,
								height: (this.edges.includes('top') ? offsetY : target.clientHeight - offsetY) / video.clientHeight,
							}, false);
						};

						const getDragListener = (event, target) => {
							const getWidth = (() => {
								if (this.edges.includes('left')) {
									const position = this.element.clientWidth - event.offsetX;

									return ({offsetX}) => offsetX + position;
								}

								const position = target.offsetWidth + event.offsetX;

								return ({offsetX}) => position - offsetX;
							})();

							const getHeight = (() => {
								if (this.edges.includes('top')) {
									const position = this.element.clientHeight - event.offsetY;

									return ({offsetY}) => offsetY + position;
								}

								const position = target.offsetHeight + event.offsetY;

								return ({offsetY}) => position - offsetY;
							})();

							return (event) => {
								this.set({
									width: getWidth(event) / video.clientWidth,
									height: getHeight(event) / video.clientHeight,
								});
							};
						};

						return async (event) => {
							if (event.buttons === 1) {
								const target = this.element.parentElement;

								if (this.isHandle) {
									this.setPanel();
								}

								await drag(event, clickListener, getDragListener(event, target), target);

								this.updateCounterpart();
							}
						};
					})());
				}

				notify() {
					for (const callback of this.callbacks) {
						callback();
					}
				}

				set isHandle(value) {
					this._isHandle = value;

					this.element.classList[value ? 'add' : 'remove'](Button.CLASS_HANDLE);
				}

				get isHandle() {
					return this._isHandle;
				}

				reset() {
					this.isHandle = true;

					for (const edge of this.edges) {
						values[edge] = 0;
					}
				}
			}

			class EdgeButton extends Button {
				constructor(edge) {
					super(edge);

					this.edge = edge;
				}

				updateCounterpart() {
					if (this.counterpart.isHandle) {
						this.counterpart.setHandle();
					}
				}

				setCrop(value = 0) {
					values[this.edge] = value;
				}

				setPanel() {
					this.isHandle = false;

					this.setCrop(handle);

					this.setHandle();
				}
			}

			class SideButton extends EdgeButton {
				flow() {
					let size = 1;

					if (top <= Button.ALLOWANCE_HANDLE) {
						size -= handle;

						this.element.style.top = `${handle * 100}%`;
					} else {
						size -= top;

						this.element.style.top = `${top * 100}%`;
					}

					if (bottom <= Button.ALLOWANCE_HANDLE) {
						size -= handle;
					} else {
						size -= bottom;
					}

					this.element.style.height = `${Math.max(0, size * 100)}%`;
				}

				setBounds(counterpart, components) {
					this.counterpart = components[counterpart];

					components.top.callbacks.push(() => {
						this.flow();
					});

					components.bottom.callbacks.push(() => {
						this.flow();
					});
				}

				setHandle(doNotify = true) {
					this.element.style.width = `${Math.min(1 - values[this.counterpart.edge], handle) * 100}%`;

					if (doNotify) {
						this.notify();
					}
				}

				set({width}, doUpdateCounterpart = true) {
					if (this.isHandle !== (this.isHandle = width <= Button.ALLOWANCE_HANDLE)) {
						this.flow();
					}

					if (doUpdateCounterpart) {
						this.updateCounterpart();
					}

					if (this.isHandle) {
						this.setCrop();

						this.setHandle();

						return;
					}

					const size = Math.min(1 - values[this.counterpart.edge], width);

					this.setCrop(size);

					this.element.style.width = `${size * 100}%`;

					this.notify();
				}

				reset(isGeneral = true) {
					super.reset();

					if (isGeneral) {
						this.element.style.top = `${handle * 100}%`;
						this.element.style.height = `${(0.5 - handle) * 200}%`;
						this.element.style.width = `${handle * 100}%`;

						return;
					}

					this.flow();

					this.setHandle();

					this.updateCounterpart();
				}
			}

			class BaseButton extends EdgeButton {
				flow() {
					let size = 1;

					if (left <= Button.ALLOWANCE_HANDLE) {
						size -= handle;

						this.element.style.left = `${handle * 100}%`;
					} else {
						size -= left;

						this.element.style.left = `${left * 100}%`;
					}

					if (right <= Button.ALLOWANCE_HANDLE) {
						size -= handle;
					} else {
						size -= right;
					}

					this.element.style.width = `${Math.max(0, size) * 100}%`;
				}

				setBounds(counterpart, components) {
					this.counterpart = components[counterpart];

					components.left.callbacks.push(() => {
						this.flow();
					});

					components.right.callbacks.push(() => {
						this.flow();
					});
				}

				setHandle(doNotify = true) {
					this.element.style.height = `${Math.min(1 - values[this.counterpart.edge], handle) * 100}%`;

					if (doNotify) {
						this.notify();
					}
				}

				set({height}, doUpdateCounterpart = false) {
					if (this.isHandle !== (this.isHandle = height <= Button.ALLOWANCE_HANDLE)) {
						this.flow();
					}

					if (doUpdateCounterpart) {
						this.updateCounterpart();
					}

					if (this.isHandle) {
						this.setCrop();

						this.setHandle();

						return;
					}

					const size = Math.min(1 - values[this.counterpart.edge], height);

					this.setCrop(size);

					this.element.style.height = `${size * 100}%`;

					this.notify();
				}

				reset(isGeneral = true) {
					super.reset();

					if (isGeneral) {
						this.element.style.left = `${handle * 100}%`;
						this.element.style.width = `${(0.5 - handle) * 200}%`;
						this.element.style.height = `${handle * 100}%`;

						return;
					}

					this.flow();

					this.setHandle();

					this.updateCounterpart();
				}
			}

			class CornerButton extends Button {
				static CLASS_NAME = 'viewfind-crop-corner';

				constructor(sectors, ...edges) {
					super(...edges);

					this.element.classList.add(CornerButton.CLASS_NAME);

					this.sectors = sectors;

					for (const sector of sectors) {
						sector.callbacks.push(this.flow.bind(this));
					}
				}

				flow() {
					let isHandle = true;

					if (this.sectors[0].isHandle) {
						this.element.style.width = `${Math.min(1 - values[this.sectors[0].counterpart.edge], handle) * 100}%`;
					} else {
						this.element.style.width = `${values[this.edges[0]] * 100}%`;

						isHandle = false;
					}

					if (this.sectors[1].isHandle) {
						this.element.style.height = `${Math.min(1 - values[this.sectors[1].counterpart.edge], handle) * 100}%`;
					} else {
						this.element.style.height = `${values[this.edges[1]] * 100}%`;

						isHandle = false;
					}

					this.isHandle = isHandle;
				}

				updateCounterpart() {
					for (const sector of this.sectors) {
						sector.updateCounterpart();
					}
				}

				set(size) {
					for (const sector of this.sectors) {
						sector.set(size);
					}
				}

				reset(isGeneral = true) {
					this.isHandle = true;

					this.element.style.width = `${handle * 100}%`;
					this.element.style.height = `${handle * 100}%`;

					if (isGeneral) {
						return;
					}

					for (const sector of this.sectors) {
						sector.reset(false);
					}
				}

				setPanel() {
					for (const sector of this.sectors) {
						sector.setPanel();
					}
				}
			}

			this.CODE = 'crop';

			this.CLASS_ABLE = 'viewfind-action-able-crop';

			const container = document.createElement('div');

			// todo ditch the containers object
			container.style.width = container.style.height = 'inherit';

			containers.foreground.append(container);

			this.reset = () => {
				for (const component of Object.values(this.components)) {
					component.reset(true);
				}
			};

			this.onRightClick = (event) => {
				if (event.target.parentElement.id === container.id) {
					return;
				}

				event.stopPropagation();
				event.preventDefault();

				if (stopDrag) {
					return;
				}

				this.reset();
			};

			this.onScroll = getOnScroll((distance) => {
				const increment = distance * $config.get().speeds.crop / zoom.value;

				this.components.top.set({height: top + Math.min((1 - top - bottom) / 2, increment)});
				this.components.left.set({width: left + Math.min((1 - left - right) / 2, increment)});

				this.components.bottom.set({height: bottom + increment});
				this.components.right.set({width: right + increment});
			});

			this.onMouseDown = (() => {
				const getDragListener = () => {
					const multiplier = $config.get().multipliers.crop;

					const setX = ((right, left, change) => {
						const clamped = Math.max(-left, Math.min(right, change * multiplier / video.clientWidth));

						this.components.left.set({width: left + clamped});
						this.components.right.set({width: right - clamped});
					}).bind(undefined, right, left);

					const setY = ((top, bottom, change) => {
						const clamped = Math.max(-top, Math.min(bottom, change * multiplier / video.clientHeight));

						this.components.top.set({height: top + clamped});

						this.components.bottom.set({height: bottom - clamped});
					}).bind(undefined, top, bottom);

					let priorEvent;

					return ({offsetX, offsetY}) => {
						if (!priorEvent) {
							priorEvent = {offsetX, offsetY};

							return;
						}

						setX(offsetX - priorEvent.offsetX);
						setY(offsetY - priorEvent.offsetY);
					};
				};

				const clickListener = () => {
					zoom.value = zoom.getFit((1 - left - right) * halfDimensions.video.width, (1 - top - bottom) * halfDimensions.video.height);

					zoom.constrain();

					position.x = (left - right) / 2;
					position.y = (bottom - top) / 2;

					position.constrain();
				};

				return (event) => {
					if (event.buttons === 1) {
						drag(event, clickListener, getDragListener(), container);
					}
				};
			})();

			this.components = {
				top: new BaseButton('top'),
				right: new SideButton('right'),
				bottom: new BaseButton('bottom'),
				left: new SideButton('left'),
			};

			this.components.top.setBounds('bottom', this.components);
			this.components.right.setBounds('left', this.components);
			this.components.bottom.setBounds('top', this.components);
			this.components.left.setBounds('right', this.components);

			this.components.topLeft = new CornerButton([this.components.left, this.components.top], 'left', 'top');
			this.components.topRight = new CornerButton([this.components.right, this.components.top], 'right', 'top');
			this.components.bottomLeft = new CornerButton([this.components.left, this.components.bottom], 'left', 'bottom');
			this.components.bottomRight = new CornerButton([this.components.right, this.components.bottom], 'right', 'bottom');

			container.append(...Object.values(this.components).map(({element}) => element));

			this.set = ({top, right, bottom, left}) => {
				this.components.top.set({height: top});
				this.components.right.set({width: right});
				this.components.bottom.set({height: bottom});
				this.components.left.set({width: left});
			};

			this.onInactive = () => {
				addListeners(this, false);

				if (crop.left === left && crop.top === top && crop.right === right && crop.bottom === bottom) {
					return;
				}

				crop.left = left;
				crop.top = top;
				crop.right = right;
				crop.bottom = bottom;

				crop.apply();
			};

			this.onActive = () => {
				const config = $config.get().crop;

				handle = config.handle / Math.max(zoom.value, 1);

				for (const component of [this.components.top, this.components.bottom, this.components.left, this.components.right]) {
					if (component.isHandle) {
						component.setHandle();
					}
				}

				crop.reveal();

				addListeners(this);

				if (!enabler.isHidingGlow) {
					glow.handleViewChange();

					glow.reset();
				}
			};

			const draggingSelector = css.getSelector(enabler.CLASS_DRAGGING);

			this.updateConfig = (() => {
				const rule = new css.Toggleable();

				return () => {
					// set handle size
					for (const button of [this.components.left, this.components.top, this.components.right, this.components.bottom]) {
						if (button.isHandle) {
							button.setHandle();
						}
					}

					rule.remove();

					const {colour} = $config.get().crop;
					const {id} = container;

					rule.add(`#${id}>:hover.${Button.CLASS_HANDLE},#${id}>:not(.${Button.CLASS_HANDLE})`, ['background-color', colour.fill]);
					rule.add(`#${id}>*`, ['border-color', colour.border]);
					rule.add(`#${id}:not(${draggingSelector} *)>:not(:hover)`, ['filter', `drop-shadow(${colour.shadow} 0 0 1px)`]);
				};
			})();

			$config.ready.then(() => {
				this.updateConfig();
			});

			container.id = 'viewfind-crop-container';

			(() => {
				const {id} = container;

				css.add(`${css.getSelector(enabler.CLASS_DRAGGING)} #${id}`, ['cursor', 'grabbing']);
				css.add(`${css.getSelector(enabler.CLASS_ABLE)} #${id}`, ['cursor', 'grab']);
				css.add(`#${id}>:not(${draggingSelector} .${Button.CLASS_HANDLE})`, ['border-style', 'solid']);
				css.add(`${draggingSelector} #${id}>.${Button.CLASS_HANDLE}`, ['filter', 'none']);

				for (const [side, sideClass] of Object.entries(Button.CLASS_EDGES)) {
					css.add(
						`${draggingSelector} #${id}>.${sideClass}.${Button.CLASS_HANDLE}~.${sideClass}.${CornerButton.CLASS_NAME}`,
						[`border-${CornerButton.OPPOSITES[side]}-style`, 'none'],
						['filter', 'none'],
					);

					// in fullscreen, 16:9 videos get an offsetLeft of 1px on my 16:9 monitor
					// I'm extending buttons by 1px so that they reach the edge of screens like mine at default zoom
					css.add(`#${id}>.${sideClass}`, [`margin-${side}`, '-1px'], [`padding-${side}`, '1px']);
				}

				css.add(`#${id}:not(.${this.CLASS_ABLE} *)`, ['display', 'none']);
			})();
		}(),

		pan: new function () {
			this.CODE = 'pan';

			this.CLASS_ABLE = 'viewfind-action-able-pan';

			this.onActive = () => {
				this.updateCrosshair();

				addListeners(this);
			};

			this.onInactive = () => {
				addListeners(this, false);
			};

			this.updateCrosshair = (() => {
				const getRoundedString = (number, decimal = 2) => {
					const raised = `${Math.round(number * Math.pow(10, decimal))}`.padStart(decimal + 1, '0');

					return `${raised.substr(0, raised.length - decimal)}.${raised.substr(raised.length - decimal)}`;
				};

				const getSigned = (ratio) => {
					const percent = Math.round(ratio * 100);

					if (percent <= 0) {
						return `${percent}`;
					}

					return `+${percent}`;
				};

				return () => {
					crosshair.text.innerText = `${getRoundedString(zoom.value)}×\n${getSigned(position.x)}%\n${getSigned(position.y)}%`;
				};
			})();

			this.onScroll = getOnScroll((distance) => {
				const increment = distance * $config.get().speeds.zoom;

				if (increment > 0) {
					zoom.value *= 1 + increment;
				} else {
					zoom.value /= 1 - increment;
				}

				zoom.constrain();

				position.constrain();

				this.updateCrosshair();
			});

			this.onRightClick = (event) => {
				event.stopImmediatePropagation();
				event.preventDefault();

				if (stopDrag) {
					return;
				}

				position.x = position.y = 0;
				zoom.value = 1;

				position.apply();

				zoom.constrain();

				this.updateCrosshair();
			};

			this.onMouseDown = (() => {
				const getDragListener = () => {
					const {multipliers} = $config.get();

					let priorEvent;

					const change = {x: 0, y: 0};

					return ({offsetX, offsetY}) => {
						if (priorEvent) {
							change.x = (priorEvent.offsetX + change.x - offsetX) * multipliers.pan;
							change.y = (priorEvent.offsetY - change.y - offsetY) * -multipliers.pan;

							position.x += change.x / video.clientWidth;
							position.y += change.y / video.clientHeight;

							position.constrain();

							this.updateCrosshair();
						}

						// events in firefox seem to lose their data after finishing propagation
						// so assigning the whole event doesn't work
						priorEvent = {offsetX, offsetY};
					};
				};

				const clickListener = (event) => {
					position.x = event.offsetX / video.clientWidth - 0.5;
					// Y increases moving down the page
					// I flip that to make trigonometry easier
					position.y = -event.offsetY / video.clientHeight + 0.5;

					position.constrain(true);

					this.updateCrosshair();
				};

				return (event) => {
					if (event.buttons === 1) {
						drag(event, clickListener, getDragListener());
					}
				};
			})();
		}(),

		rotate: new function () {
			this.CODE = 'rotate';

			this.CLASS_ABLE = 'viewfind-action-able-rotate';

			this.onActive = () => {
				this.updateCrosshair();

				addListeners(this);
			};

			this.onInactive = () => {
				addListeners(this, false);
			};

			this.updateCrosshair = () => {
				const angle = DEGREES[90] - rotation.value;

				crosshair.text.innerText = `${Math.floor((DEGREES[90] - rotation.value) / Math.PI * 180)}°\n≈${Math.round(angle / DEGREES[90]) % 4 * 90}°`;
			};

			this.onScroll = getOnScroll((distance) => {
				rotation.value += distance * $config.get().speeds.rotate;

				rotation.constrain();

				zoom.constrain();
				position.constrain();

				this.updateCrosshair();
			});

			this.onRightClick = (event) => {
				event.stopImmediatePropagation();
				event.preventDefault();

				if (stopDrag) {
					return;
				}

				rotation.value = DEGREES[90];

				rotation.apply();

				zoom.constrain();
				position.constrain();

				this.updateCrosshair();
			};

			this.onMouseDown = (() => {
				const getDragListener = () => {
					const {multipliers} = $config.get();
					const middleX = containers.tracker.clientWidth / 2;
					const middleY = containers.tracker.clientHeight / 2;

					const priorPosition = position.getValues();
					const priorZoom = zoom.value;

					let priorMouseTheta;

					return (event) => {
						const mouseTheta = getTheta(middleX, middleY, event.offsetX, event.offsetY);

						if (priorMouseTheta === undefined) {
							priorMouseTheta = mouseTheta;

							return;
						}

						position.x = priorPosition.x;
						position.y = priorPosition.y;
						zoom.value = priorZoom;

						rotation.value += (priorMouseTheta - mouseTheta) * multipliers.rotate;

						rotation.constrain();

						zoom.constrain();
						position.constrain();

						this.updateCrosshair();

						priorMouseTheta = mouseTheta;
					};
				};

				const clickListener = () => {
					rotation.value = Math.round(rotation.value / DEGREES[90]) * DEGREES[90];

					rotation.constrain();

					zoom.constrain();
					position.constrain();

					this.updateCrosshair();
				};

				return (event) => {
					if (event.buttons === 1) {
						drag(event, clickListener, getDragListener(), containers.tracker);
					}
				};
			})();
		}(),

		configure: new function () {
			this.CODE = 'config';

			this.onActive = async () => {
				await $config.edit();

				updateConfigs();

				viewport.focus();

				glow.reset();

				position.constrain();
				zoom.constrain();
			};
		}(),

		reset: new function () {
			this.CODE = 'reset';

			this.onActive = () => {
				if (this.restore) {
					this.restore();
				} else {
					this.restore = peek();
				}
			};
		}(),
	};
})();

const crosshair = new function () {
	this.container = document.createElement('div');

	this.lines = {
		horizontal: document.createElement('div'),
		vertical: document.createElement('div'),
	};

	this.text = document.createElement('div');

	const id = 'viewfind-crosshair';

	this.container.id = id;
	this.container.classList.add(CLASS_VIEWFINDER);

	css.add(`#${id}:not(${css.getSelector(actions.pan.CLASS_ABLE)} *):not(${css.getSelector(actions.rotate.CLASS_ABLE)} *)`, ['display', 'none']);

	this.lines.horizontal.style.position = this.lines.vertical.style.position = this.text.style.position = this.container.style.position = 'absolute';

	this.lines.horizontal.style.top = '50%';
	this.lines.horizontal.style.width = '100%';

	this.lines.vertical.style.left = '50%';
	this.lines.vertical.style.height = '100%';

	this.text.style.userSelect = 'none';

	this.container.style.top = '0';
	this.container.style.width = '100%';
	this.container.style.height = '100%';
	this.container.style.pointerEvents = 'none';

	this.container.append(this.lines.horizontal, this.lines.vertical);

	this.clip = () => {
		const {outer, inner, gap} = $config.get().crosshair;

		const thickness = Math.max(inner, outer);

		const {width, height} = halfDimensions.viewport;
		const halfGap = gap / 2;

		const startInner = (thickness - inner) / 2;
		const startOuter = (thickness - outer) / 2;

		const endInner = thickness - startInner;
		const endOuter = thickness - startOuter;

		this.lines.horizontal.style.clipPath = 'path(\''
			+ `M0 ${startOuter}L${width - halfGap} ${startOuter}L${width - halfGap} ${startInner}L${width + halfGap} ${startInner}L${width + halfGap} ${startOuter}L${viewport.clientWidth} ${startOuter}`
			+ `L${viewport.clientWidth} ${endOuter}L${width + halfGap} ${endOuter}L${width + halfGap} ${endInner}L${width - halfGap} ${endInner}L${width - halfGap} ${endOuter}L0 ${endOuter}`
			+ 'Z\')';

		this.lines.vertical.style.clipPath = 'path(\''
			+ `M${startOuter} 0L${startOuter} ${height - halfGap}L${startInner} ${height - halfGap}L${startInner} ${height + halfGap}L${startOuter} ${height + halfGap}L${startOuter} ${viewport.clientHeight}`
			+ `L${endOuter} ${viewport.clientHeight}L${endOuter} ${height + halfGap}L${endInner} ${height + halfGap}L${endInner} ${height - halfGap}L${endOuter} ${height - halfGap}L${endOuter} 0`
			+ 'Z\')';
	};

	this.updateConfig = (doClip = true) => {
		const {colour, outer, inner, text} = $config.get().crosshair;
		const thickness = Math.max(inner, outer);

		this.container.style.filter = `drop-shadow(${colour.shadow} 0 0 1px)`;

		this.lines.horizontal.style.translate = `0 -${thickness / 2}px`;
		this.lines.vertical.style.translate = `-${thickness / 2}px 0`;

		this.lines.horizontal.style.height = this.lines.vertical.style.width = `${thickness}px`;

		this.lines.horizontal.style.backgroundColor = this.lines.vertical.style.backgroundColor = colour.fill;

		if (text) {
			this.text.style.color = colour.fill;

			this.text.style.font = text.font;
			this.text.style.left = `${text.position.x}%`;
			this.text.style.top = `${text.position.y}%`;
			this.text.style.transform = `translate(${text.translate.x}%,${text.translate.y}%) translate(${text.offset.x}px,${text.offset.y}px)`;
			this.text.style.textAlign = text.align;
			this.text.style.lineHeight = text.height;

			this.container.append(this.text);
		} else {
			this.text.remove();
		}

		if (doClip) {
			this.clip();
		}
	};

	$config.ready.then(() => {
		this.updateConfig(false);
	});
}();

// ELEMENT CHANGE LISTENERS

const observer = new function () {
	const onResolutionChange = () => {
		glow.handleSizeChange?.();
	};

	const styleObserver = new MutationObserver((() => {
		const properties = ['top', 'left', 'width', 'height', 'scale', 'rotate', 'translate', 'transform-origin'];

		let priorStyle;

		return () => {
			// mousemove events on video with ctrlKey=true trigger this but have no effect
			if (video.style.cssText === priorStyle) {
				return;
			}

			priorStyle = video.style.cssText;

			for (const property of properties) {
				containers.background.style[property] = video.style[property];
				containers.foreground.style[property] = video.style[property];

				// cinematics doesn't exist for embedded vids
				if (cinematics) {
					cinematics.style[property] = video.style[property];
				}
			}

			glow.handleViewChange();
		};
	})());

	const videoObserver = new ResizeObserver(() => {
		handleVideoChange();

		glow.handleSizeChange?.();
	});

	const viewportObserver = new ResizeObserver(() => {
		handleViewportChange();

		crosshair.clip();
	});

	this.start = () => {
		video.addEventListener('resize', onResolutionChange);

		styleObserver.observe(video, {attributes: true, attributeFilter: ['style']});
		viewportObserver.observe(viewport);
		videoObserver.observe(video);

		glow.handleViewChange();
	};

	this.stop = () => {
		video.removeEventListener('resize', onResolutionChange);

		styleObserver.disconnect();
		viewportObserver.disconnect();
		videoObserver.disconnect();
	};
}();

// NAVIGATION LISTENERS

const stop = () => {
	if (stopped) {
		return;
	}

	stopped = true;

	enabler.stop();

	stopDrag?.();

	observer.stop();

	containers.background.remove();
	containers.foreground.remove();
	containers.tracker.remove();
	crosshair.container.remove();

	return peek(true);
};

const start = () => {
	if (!stopped || viewport.classList.contains('ad-showing')) {
		return;
	}

	stopped = false;

	observer.start();

	glow.start();

	viewport.append(containers.background, containers.foreground, containers.tracker, crosshair.container);

	// User may have a static minimum zoom greater than 1
	zoom.constrain();

	enabler.handleChange();
};

const updateConfigs = () => {
	ConfigCache.id++;

	enabler.updateConfig();
	actions.crop.updateConfig();
	crosshair.updateConfig();
};

// LISTENER ASSIGNMENTS

// load & navigation
(() => {
	const getNode = (node, selector, ...selectors) => new Promise((resolve) => {
		for (const child of node.children) {
			if (child.matches(selector)) {
				resolve(selectors.length === 0 ? child : getNode(child, ...selectors));

				return;
			}
		}

		new MutationObserver((changes, observer) => {
			for (const {addedNodes} of changes) {
				for (const child of addedNodes) {
					if (child.matches(selector)) {
						resolve(selectors.length === 0 ? child : getNode(child, ...selectors));

						observer.disconnect();

						return;
					}
				}
			}
		}).observe(node, {childList: true});
	});

	const setupConfigFailsafe = (parent) => {
		new MutationObserver((changes) => {
			for (const {addedNodes} of changes) {
				for (const node of addedNodes) {
					if (!node.classList.contains('ytp-contextmenu')) {
						continue;
					}

					const container = node.querySelector('.ytp-panel-menu');
					const option = container.lastElementChild.cloneNode(true);

					option.children[0].style.visibility = 'hidden';
					option.children[1].innerText = 'Configure Viewfinding';

					option.addEventListener('click', ({button}) => {
						if (button === 0) {
							actions.configure.onActive();
						}
					});

					container.appendChild(option);

					new ResizeObserver((_, observer) => {
						if (container.clientWidth === 0) {
							option.remove();

							observer.disconnect();
						}
					}).observe(container);
				}
			}
		}).observe(parent, {childList: true});
	};

	const init = async () => {
		if (unsafeWindow.ytplayer?.bootstrapPlayerContainer?.childElementCount > 0) {
			// wait for the video to be moved to ytd-app
			await new Promise((resolve) => {
				new MutationObserver((changes, observer) => {
					resolve();

					observer.disconnect();
				}).observe(unsafeWindow.ytplayer.bootstrapPlayerContainer, {childList: true});
			});
		}

		try {
			await $config.ready;
		} catch (error) {
			if (!$config.reset || !window.confirm(`${error.message}\n\nWould you like to erase your data?`)) {
				console.error(error);

				return;
			}

			await $config.reset();

			updateConfigs();
		}

		if (isEmbed) {
			video = document.body.querySelector(SELECTOR_VIDEO);
		} else {
			const pageManager = await getNode(document.body, 'ytd-app', '#content', 'ytd-page-manager');

			const page = pageManager.getCurrentPage() ?? await new Promise((resolve) => {
				new MutationObserver(([{addedNodes: [page]}], observer) => {
					if (page) {
						resolve(page);

						observer.disconnect();
					}
				}).observe(pageManager, {childList: true});
			});

			await page.playerEl.getPlayerPromise();

			video = page.playerEl.querySelector(SELECTOR_VIDEO);
			cinematics = page.querySelector('#cinematics');

			// navigation to a new video
			new MutationObserver(() => {
				video.removeEventListener('play', startIfReady);

				power.off();

				// this callback can occur after metadata loads
				startIfReady();
			}).observe(page, {attributes: true, attributeFilter: ['video-id']});

			// navigation to a non-video page
			new MutationObserver(() => {
				if (video.src === '') {
					video.removeEventListener('play', startIfReady);

					power.off();
				}
			}).observe(video, {attributes: true, attributeFilter: ['src']});
		}

		viewport = video.parentElement.parentElement;
		altTarget = viewport.parentElement;

		containers.foreground.style.zIndex = crosshair.container.style.zIndex = video.parentElement.computedStyleMap?.().get('z-index').value ?? 10;
		crosshair.clip();

		handleVideoChange();
		handleViewportChange();

		setupConfigFailsafe(document.body);
		setupConfigFailsafe(viewport);

		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或关注我们的公众号极客氢云获取最新地址