- // ==UserScript==
- // @name YouTube Viewfinding
- // @version 0.14
- // @description Zoom, rotate & crop YouTube videos
- // @author Callum Latham
- // @namespace https://gf.qytechs.cn/users/696211-ctl2
- // @license GNU GPLv3
- // @compatible chrome
- // @compatible edge
- // @compatible firefox Video dimensions affect page scrolling
- // @compatible opera Video dimensions affect page scrolling
- // @match *://www.youtube.com/*
- // @match *://youtube.com/*
- // @require https://update.gf.qytechs.cn/scripts/446506/1537901/%24Config.js
- // @grant GM.setValue
- // @grant GM.getValue
- // @grant GM.deleteValue
- // ==/UserScript==
-
- /* global $Config */
-
- (() => {
- // Don't run in non-embed frames (e.g. stream chat frame)
- if (window.parent !== window && window.location.pathname.split('/')[1] !== 'embed') {
- return;
- }
-
- const VAR_ZOOM = '--viewfind-zoom';
- const LIMITS = {none: 'None', static: 'Static', fit: 'Fit'};
-
- const $config = new $Config(
- 'VIEWFIND_TREE',
- (() => {
- const isCSSRule = (() => {
- const wrapper = document.createElement('style');
- const regex = /\s/g;
-
- return (property, text) => {
- const ruleText = `${property}:${text};`;
-
- document.head.appendChild(wrapper);
- wrapper.sheet.insertRule(`:not(*){${ruleText}}`);
-
- const [{style: {cssText}}] = wrapper.sheet.cssRules;
-
- wrapper.remove();
-
- return cssText.replaceAll(regex, '') === ruleText.replaceAll(regex, '') || `Must be a valid CSS ${property} rule`;
- };
- })();
-
- const getHideId = (() => {
- let id = -1;
-
- return () => ++id;
- })();
-
- const glowHideId = getHideId();
-
- return {
- get: (_, configs) => Object.assign(...configs),
- children: [
- {
- label: 'Controls',
- children: [
- {
- label: 'Keybinds',
- descendantPredicate: (children) => {
- const isMatch = ({children: a}, {children: b}) => {
- if (a.length !== b.length) {
- return false;
- }
-
- return a.every(({value: keyA}) => b.some(({value: keyB}) => keyA === keyB));
- };
-
- for (let i = 1; i < children.length; ++i) {
- if (children.slice(i).some((child) => isMatch(children[i - 1], child))) {
- return 'Another action has this key combination';
- }
- }
-
- return true;
- },
- get: (_, configs) => ({keys: Object.assign(...configs)}),
- children: (() => {
- const seed = {
- value: '',
- listeners: {
- keydown: (event) => {
- switch (event.key) {
- case 'Enter':
- case 'Escape':
- return;
- }
-
- event.preventDefault();
-
- event.target.value = event.code;
-
- event.target.dispatchEvent(new InputEvent('input'));
- },
- },
- };
-
- const getKeys = (children) => new Set(children.map(({value}) => value));
-
- const getNode = (label, keys, get) => ({
- label,
- seed,
- children: keys.map((value) => ({...seed, value})),
- get,
- });
-
- return [
- {
- label: 'Actions',
- get: (_, [toggle, ...controls]) => Object.assign(...controls.map(({id, keys}) => ({
- [id]: {
- toggle,
- keys,
- },
- }))),
- children: [
- {
- label: 'Toggle?',
- value: false,
- get: ({value}) => value,
- },
- ...[
- ['Pan / Zoom', ['KeyZ'], 'pan'],
- ['Rotate', ['IntlBackslash'], 'rotate'],
- ['Crop', ['KeyZ', 'IntlBackslash'], 'crop'],
- ].map(([label, keys, id]) => getNode(label, keys, ({children}) => ({id, keys: getKeys(children)}))),
- ],
- },
- getNode('Reset', ['KeyX'], ({children}) => ({reset: {keys: getKeys(children)}})),
- getNode('Configure', ['AltLeft', 'KeyX'], ({children}) => ({config: {keys: getKeys(children)}})),
- ];
- })(),
- },
- {
- label: 'Scroll Speeds',
- get: (_, configs) => ({speeds: Object.assign(...configs)}),
- children: [
- {
- label: 'Zoom',
- value: -100,
- get: ({value}) => ({zoom: value / 150000}),
- },
- {
- label: 'Rotate',
- value: -100,
- // 150000 * (5 - 0.8) / 2π ≈ 100000
- get: ({value}) => ({rotate: value / 100000}),
- },
- {
- label: 'Crop',
- value: -100,
- get: ({value}) => ({crop: value / 300000}),
- },
- ],
- },
- {
- label: 'Drag Inversions',
- get: (_, configs) => ({multipliers: Object.assign(...configs)}),
- children: [
- ['Pan', 'pan'],
- ['Rotate', 'rotate'],
- ['Crop', 'crop'],
- ].map(([label, key, value = false]) => ({
- label,
- value,
- get: ({value}) => ({[key]: value ? -1 : 1}),
- })),
- },
- {
- label: 'Click Movement Allowance (px)',
- value: 2,
- predicate: (value) => value >= 0 || 'Allowance must be positive',
- inputAttributes: {min: 0},
- get: ({value: clickCutoff}) => ({clickCutoff}),
- },
- ],
- },
- {
- label: 'Behaviour',
- children: [
- ...(() => {
- const typeNode = {
- label: 'Type',
- get: ({value}) => ({type: value}),
- };
-
- const staticNode = {
- label: 'Value (%)',
- predicate: (value) => value >= 0 || 'Limit must be positive',
- inputAttributes: {min: 0},
- get: ({value}) => ({custom: value / 100}),
- };
-
- const fitNode = {
- label: 'Glow Allowance (%)',
- predicate: (value) => value >= 0 || 'Allowance must be positive',
- inputAttributes: {min: 0},
- get: ({value}) => ({frame: value / 100}),
- };
-
- const options = Object.values(LIMITS);
-
- const getNode = (label, key, value, customValue, glowAllowance = 300) => {
- const staticId = getHideId();
- const fitId = getHideId();
- const onUpdate = (value) => ({
- hide: {
- [staticId]: value !== LIMITS.static,
- [fitId]: value !== LIMITS.fit,
- },
- });
-
- return {
- label,
- get: (_, configs) => ({[key]: Object.assign(...configs)}),
- children: [
- {...typeNode, value, options, onUpdate},
- {...staticNode, value: customValue, hideId: staticId},
- {...fitNode, value: glowAllowance, hideId: fitId},
- ],
- };
- };
-
- return [
- getNode('Zoom In Limit', 'zoomInLimit', LIMITS.static, 500, 0),
- getNode('Zoom Out Limit', 'zoomOutLimit', LIMITS.static, 80),
- getNode('Pan Limit', 'panLimit', LIMITS.static, 50),
- {
- label: 'Snap Pan Limit',
- get: (_, configs) => ({snapPanLimit: Object.assign(...configs)}),
- children: ((hideId) => [
- {
- ...typeNode,
- value: LIMITS.fit,
- options: [LIMITS.none, LIMITS.fit],
- onUpdate: (value) => ({hide: {[hideId]: value !== LIMITS.fit}}),
- },
- {...fitNode, value: 0, hideId},
- ])(getHideId()),
- },
- ];
- })(),
- {
- label: 'While Viewfinding',
- get: (_, configs) => {
- const {overlayKill, overlayHide, ...config} = Object.assign(...configs);
-
- return {
- active: {
- overlayRule: overlayKill && [overlayHide ? 'display' : 'pointer-events', 'none'],
- ...config,
- },
- };
- },
- children: [
- {
- label: 'Pause Video?',
- value: false,
- get: ({value: pause}) => ({pause}),
- },
- {
- label: 'Hide Glow?',
- value: false,
- get: ({value: hideGlow}) => ({hideGlow}),
- hideId: glowHideId,
- },
- ...((hideId) => [
- {
- label: 'Disable Overlay?',
- value: true,
- get: ({value: overlayKill}, configs) => Object.assign({overlayKill}, ...configs),
- onUpdate: (value) => ({hide: {[hideId]: !value}}),
- children: [
- {
- label: 'Hide Overlay?',
- value: false,
- get: ({value: overlayHide}) => ({overlayHide}),
- hideId,
- },
- ],
- },
- ])(getHideId()),
- ],
- },
-
- ],
- },
- {
- label: 'Glow',
- value: true,
- onUpdate: (value) => ({hide: {[glowHideId]: !value}}),
- get: ({value: on}, configs) => {
- if (!on) {
- return {};
- }
-
- const {turnover, ...config} = Object.assign(...configs);
- const sampleCount = Math.floor(config.fps * turnover);
-
- // avoid taking more samples than there's space for
- if (sampleCount > config.size) {
- const fps = config.size / turnover;
-
- return {
- glow: {
- ...config,
- sampleCount: config.size,
- interval: 1000 / fps,
- fps,
- },
- };
- }
-
- return {
- glow: {
- ...config,
- interval: 1000 / config.fps,
- sampleCount,
- },
- };
- },
- children: [
- (() => {
- const [seed, getChild] = (() => {
- const options = ['blur', 'brightness', 'contrast', 'drop-shadow', 'grayscale', 'hue-rotate', 'invert', 'opacity', 'saturate', 'sepia'];
- const ids = {};
- const hide = {};
-
- for (const option of options) {
- ids[option] = getHideId();
-
- hide[ids[option]] = true;
- }
-
- const min0Amount = {
- label: 'Amount (%)',
- value: 100,
- predicate: (value) => value >= 0 || 'Amount must be positive',
- inputAttributes: {min: 0},
- };
-
- const max100Amount = {
- label: 'Amount (%)',
- value: 0,
- predicate: (value) => {
- if (value < 0) {
- return 'Amount must be positive';
- }
-
- return value <= 100 || 'Amount may not exceed 100%';
- },
- inputAttributes: {min: 0, max: 100},
- };
-
- const getScaled = (value) => `calc(${value}px/var(${VAR_ZOOM}))`;
-
- const root = {
- label: 'Function',
- options,
- value: options[0],
- get: ({value}, configs) => {
- const config = Object.assign(...configs);
-
- switch (value) {
- case options[0]:
- return {
- filter: config.blurScale ? `blur(${config.blur}px)` : `blur(${getScaled(config.blur)})`,
- blur: {
- x: config.blur,
- y: config.blur,
- scale: config.blurScale,
- },
- };
-
- case options[3]:
- return {
- filter: config.shadowScale ?
- `drop-shadow(${config.shadow} ${config.shadowX}px ${config.shadowY}px ${config.shadowSpread}px)` :
- `drop-shadow(${config.shadow} ${getScaled(config.shadowX)} ${getScaled(config.shadowY)} ${getScaled(config.shadowSpread)})`,
- blur: {
- x: config.shadowSpread + Math.abs(config.shadowX),
- y: config.shadowSpread + Math.abs(config.shadowY),
- scale: config.shadowScale,
- },
- };
-
- case options[5]:
- return {filter: `hue-rotate(${config.hueRotate}deg)`};
- }
-
- return {filter: `${value}(${config[value]}%)`};
- },
- onUpdate: (value) => ({hide: {...hide, [ids[value]]: false}}),
- };
-
- const children = {
- 'blur': [
- {
- label: 'Distance (px)',
- value: 0,
- get: ({value}) => ({blur: value}),
- predicate: (value) => value >= 0 || 'Distance must be positive',
- inputAttributes: {min: 0},
- hideId: ids.blur,
- },
- {
- label: 'Scale?',
- value: false,
- get: ({value}) => ({blurScale: value}),
- hideId: ids.blur,
- },
- ],
- 'brightness': [
- {
- ...min0Amount,
- hideId: ids.brightness,
- get: ({value}) => ({brightness: value}),
- },
- ],
- 'contrast': [
- {
- ...min0Amount,
- hideId: ids.contrast,
- get: ({value}) => ({contrast: value}),
- },
- ],
- 'drop-shadow': [
- {
- label: 'Colour',
- input: 'color',
- value: '#FFFFFF',
- get: ({value}) => ({shadow: value}),
- hideId: ids['drop-shadow'],
- },
- {
- label: 'Horizontal Offset (px)',
- value: 0,
- get: ({value}) => ({shadowX: value}),
- hideId: ids['drop-shadow'],
- },
- {
- label: 'Vertical Offset (px)',
- value: 0,
- get: ({value}) => ({shadowY: value}),
- hideId: ids['drop-shadow'],
- },
- {
- label: 'Spread (px)',
- value: 0,
- predicate: (value) => value >= 0 || 'Spread must be positive',
- inputAttributes: {min: 0},
- get: ({value}) => ({shadowSpread: value}),
- hideId: ids['drop-shadow'],
- },
- {
- label: 'Scale?',
- value: true,
- get: ({value}) => ({shadowScale: value}),
- hideId: ids['drop-shadow'],
- },
- ],
- 'grayscale': [
- {
- ...max100Amount,
- hideId: ids.grayscale,
- get: ({value}) => ({grayscale: value}),
- },
- ],
- 'hue-rotate': [
- {
- label: 'Angle (deg)',
- value: 0,
- get: ({value}) => ({hueRotate: value}),
- hideId: ids['hue-rotate'],
- },
- ],
- 'invert': [
- {
- ...max100Amount,
- hideId: ids.invert,
- get: ({value}) => ({invert: value}),
- },
- ],
- 'opacity': [
- {
- ...max100Amount,
- value: 100,
- hideId: ids.opacity,
- get: ({value}) => ({opacity: value}),
- },
- ],
- 'saturate': [
- {
- ...min0Amount,
- hideId: ids.saturate,
- get: ({value}) => ({saturate: value}),
- },
- ],
- 'sepia': [
- {
- ...max100Amount,
- hideId: ids.sepia,
- get: ({value}) => ({sepia: value}),
- },
- ],
- };
-
- return [
- {...root, children: Object.values(children).flat()}, (id, ...values) => {
- const replacements = [];
-
- for (const [i, child] of children[id].entries()) {
- replacements.push({...child, value: values[i]});
- }
-
- return {
- ...root,
- value: id,
- children: Object.values({...children, [id]: replacements}).flat(),
- };
- },
- ];
- })();
-
- return {
- label: 'Filter',
- get: (_, configs) => {
- const scaled = {x: 0, y: 0};
- const unscaled = {x: 0, y: 0};
-
- let filter = '';
-
- for (const config of configs) {
- filter += config.filter;
-
- if ('blur' in config) {
- const target = config.blur.scale ? scaled : unscaled;
-
- target.x = Math.max(target.x, config.blur.x);
- target.y = Math.max(target.y, config.blur.y);
- }
- }
-
- return {filter, blur: {scaled, unscaled}};
- },
- children: [
- getChild('saturate', 150),
- getChild('brightness', 150),
- getChild('blur', 25, false),
- ],
- seed,
- };
- })(),
- {
- label: 'Update',
- childPredicate: ([{value: fps}, {value: turnover}]) => fps * turnover >= 1 || `${turnover} second turnover cannot be achieved at ${fps} hertz`,
- children: [
- {
- label: 'Frequency (Hz)',
- value: 15,
- predicate: (value) => {
- if (value > 144) {
- return 'Update frequency may not be above 144 hertz';
- }
-
- return value >= 0 || 'Update frequency must be positive';
- },
- inputAttributes: {min: 0, max: 144},
- get: ({value: fps}) => ({fps}),
- },
- {
- label: 'Turnover Time (s)',
- value: 3,
- predicate: (value) => value >= 0 || 'Turnover time must be positive',
- inputAttributes: {min: 0},
- get: ({value: turnover}) => ({turnover}),
- },
- {
- label: 'Reverse?',
- value: false,
- get: ({value: doFlip}) => ({doFlip}),
- },
- ],
- },
- {
- label: 'Size (px)',
- value: 50,
- predicate: (value) => value >= 0 || 'Size must be positive',
- inputAttributes: {min: 0},
- get: ({value}) => ({size: value}),
- },
- {
- label: 'End Point (%)',
- value: 103,
- predicate: (value) => value >= 0 || 'End point must be positive',
- inputAttributes: {min: 0},
- get: ({value}) => ({end: value / 100}),
- },
- ].map((node) => ({...node, hideId: glowHideId})),
- },
- {
- label: 'Interfaces',
- children: [
- {
- label: 'Crop',
- get: (_, configs) => ({crop: Object.assign(...configs)}),
- children: [
- {
- label: 'Colours',
- get: (_, configs) => ({colour: Object.assign(...configs)}),
- children: [
- {
- label: 'Fill',
- get: (_, [colour, opacity]) => ({fill: `${colour}${opacity}`}),
- children: [
- {
- label: 'Colour',
- value: '#808080',
- input: 'color',
- get: ({value}) => value,
- },
- {
- label: 'Opacity (%)',
- value: 40,
- predicate: (value) => {
- if (value < 0) {
- return 'Opacity must be positive';
- }
-
- return value <= 100 || 'Opacity may not exceed 100%';
- },
- inputAttributes: {min: 0, max: 100},
- get: ({value}) => Math.round(255 * value / 100).toString(16),
- },
- ],
- },
- {
- label: 'Shadow',
- value: '#000000',
- input: 'color',
- get: ({value: shadow}) => ({shadow}),
- },
- {
- label: 'Border',
- value: '#ffffff',
- input: 'color',
- get: ({value: border}) => ({border}),
- },
- ],
- },
- {
- label: 'Handle Size (%)',
- value: 6,
- predicate: (value) => {
- if (value < 0) {
- return 'Size must be positive';
- }
-
- return value <= 50 || 'Size may not exceed 50%';
- },
- inputAttributes: {min: 0, max: 50},
- get: ({value}) => ({handle: value / 100}),
- },
- ],
- },
- {
- label: 'Crosshair',
- get: (value, configs) => ({crosshair: Object.assign(...configs)}),
- children: [
- {
- label: 'Outer Thickness (px)',
- value: 3,
- predicate: (value) => value >= 0 || 'Thickness must be positive',
- inputAttributes: {min: 0},
- get: ({value: outer}) => ({outer}),
- },
- {
- label: 'Inner Thickness (px)',
- value: 1,
- predicate: (value) => value >= 0 || 'Thickness must be positive',
- inputAttributes: {min: 0},
- get: ({value: inner}) => ({inner}),
- },
- {
- label: 'Inner Diameter (px)',
- value: 157,
- predicate: (value) => value >= 0 || 'Diameter must be positive',
- inputAttributes: {min: 0},
- get: ({value: gap}) => ({gap}),
- },
- ((hideId) => ({
- label: 'Text',
- value: true,
- onUpdate: (value) => ({hide: {[hideId]: !value}}),
- get: ({value}, configs) => {
- if (!value) {
- return {};
- }
-
- const {translateX, translateY, ...config} = Object.assign(...configs);
-
- return {
- text: {
- translate: {
- x: translateX,
- y: translateY,
- },
- ...config,
- },
- };
- },
- children: [
- {
- label: 'Font',
- value: '30px "Harlow Solid", cursive',
- predicate: isCSSRule.bind(null, 'font'),
- get: ({value: font}) => ({font}),
- },
- {
- label: 'Position (%)',
- get: (_, configs) => ({position: Object.assign(...configs)}),
- children: ['x', 'y'].map((label) => ({
- label,
- value: 0,
- predicate: (value) => Math.abs(value) <= 50 || 'Position must be on-screen',
- inputAttributes: {min: -50, max: 50},
- get: ({value}) => ({[label]: value + 50}),
- })),
- },
- {
- label: 'Offset (px)',
- get: (_, configs) => ({offset: Object.assign(...configs)}),
- children: [
- {
- label: 'x',
- value: -6,
- get: ({value: x}) => ({x}),
- },
- {
- label: 'y',
- value: -25,
- get: ({value: y}) => ({y}),
- },
- ],
- },
- (() => {
- const options = ['Left', 'Center', 'Right'];
-
- return {
- label: 'Alignment',
- value: options[2],
- options,
- get: ({value}) => ({align: value.toLowerCase(), translateX: options.indexOf(value) * -50}),
- };
- })(),
- (() => {
- const options = ['Top', 'Middle', 'Bottom'];
-
- return {
- label: 'Baseline',
- value: options[0],
- options,
- get: ({value}) => ({translateY: options.indexOf(value) * -50}),
- };
- })(),
- {
- label: 'Line height (%)',
- value: 90,
- predicate: (value) => value >= 0 || 'Height must be positive',
- inputAttributes: {min: 0},
- get: ({value}) => ({height: value / 100}),
- },
- ].map((node) => ({...node, hideId})),
- }))(getHideId()),
- {
- label: 'Colours',
- get: (_, configs) => ({colour: Object.assign(...configs)}),
- children: [
- {
- label: 'Fill',
- value: '#ffffff',
- input: 'color',
- get: ({value: fill}) => ({fill}),
- },
- {
- label: 'Shadow',
- value: '#000000',
- input: 'color',
- get: ({value: shadow}) => ({shadow}),
- },
- ],
- },
- ],
- },
- ],
- },
- ],
- };
- })(),
- {
- headBase: '#c80000',
- headButtonExit: '#000000',
- borderHead: '#ffffff',
- borderTooltip: '#c80000',
- width: Math.min(90, screen.width / 16),
- height: 90,
- },
- {
- zIndex: 10000,
- scrollbarColor: 'initial',
- },
- );
-
- const CLASS_VIEWFINDER = 'viewfind-element';
- const PI_HALVES = [Math.PI / 2, Math.PI, 3 * Math.PI / 2, Math.PI * 2];
- const SELECTOR_VIDEO = '#movie_player video.html5-main-video';
-
- // STATE
-
- let video;
- let altTarget;
- let viewport;
- let cinematics;
-
- let stopped = true;
- let stopDrag;
-
- const viewportAngles = new function () {
- this.set = () => {
- this.side = getTheta(0, 0, viewport.clientWidth, viewport.clientHeight);
-
- // equals `getTheta(0, 0, viewport.clientHeight, viewport.clientWidth)`
- this.base = PI_HALVES[0] - this.side;
-
- glow.handleViewChange(true);
- };
- }();
-
- // ROTATION HELPERS
-
- const getTheta = (fromX, fromY, toX, toY) => Math.atan2(toY - fromY, toX - fromX);
-
- const getRotatedCorners = (x, y) => {
- const angle = rotation.value - PI_HALVES[0];
- const radius = Math.sqrt(x * x + y * y);
-
- const topAngle = getTheta(0, 0, x, y) + angle;
- const bottomAngle = getTheta(0, 0, x, -y) + angle;
-
- return [
- {
- x: Math.abs(radius * Math.cos(topAngle)),
- y: Math.abs(radius * Math.sin(topAngle)),
- },
- {
- x: Math.abs(radius * Math.cos(bottomAngle)),
- y: Math.abs(radius * Math.sin(bottomAngle)),
- },
- ];
- };
-
- // CSS HELPER
-
- const css = new function () {
- this.has = (name) => document.body.classList.contains(name);
- this.tag = (name, doAdd = true) => document.body.classList[doAdd ? 'add' : 'remove'](name);
-
- this.getSelector = (...classes) => `body.${classes.join('.')}`;
-
- const getSheet = () => {
- const element = document.createElement('style');
-
- document.head.appendChild(element);
-
- return element.sheet;
- };
-
- const getRuleString = (selector, ...declarations) => `${selector}{${declarations.map(([property, value]) => `${property}:${value};`).join('')}}`;
-
- this.add = function (...rule) {
- this.insertRule(getRuleString(...rule));
- }.bind(getSheet());
-
- this.Toggleable = class {
- static sheet = getSheet();
-
- static active = [];
-
- static id = 0;
-
- static add(rule, id) {
- this.sheet.insertRule(rule, this.active.length);
-
- this.active.push(id);
- }
-
- static remove(id) {
- let index = this.active.indexOf(id);
-
- while (index >= 0) {
- this.sheet.deleteRule(index);
-
- this.active.splice(index, 1);
-
- index = this.active.indexOf(id);
- }
- }
-
- id = this.constructor.id++;
-
- add(...rule) {
- this.constructor.add(getRuleString(...rule), this.id);
- }
-
- remove() {
- this.constructor.remove(this.id);
- }
- };
- }();
-
- // ACTION MANAGER
-
- const enabler = new function () {
- this.CLASS_ABLE = 'viewfind-action-able';
- this.CLASS_DRAGGING = 'viewfind-action-dragging';
-
- this.keys = new Set();
-
- this.didPause = false;
- this.isHidingGlow = false;
-
- this.setActive = (action) => {
- const {active, keys} = $config.get();
-
- if (active.hideGlow && Boolean(action) !== this.isHidingGlow) {
- if (action) {
- this.isHidingGlow = true;
-
- glow.hide();
- } else if (this.isHidingGlow) {
- this.isHidingGlow = false;
-
- glow.show();
- }
- }
-
- this.activeAction?.onInactive?.();
-
- if (action) {
- this.activeAction = action;
- this.toggled = keys[action.CODE].toggle;
-
- action.onActive?.();
-
- if (active.pause && !video.paused) {
- video.pause();
-
- this.didPause = true;
- }
-
- return;
- }
-
- if (this.didPause) {
- video.play();
-
- this.didPause = false;
- }
-
- this.activeAction = this.toggled = undefined;
- };
-
- this.handleChange = () => {
- if (stopped || stopDrag || video.ended) {
- return;
- }
-
- const {keys} = $config.get();
-
- let activeAction;
-
- for (const action of Object.values(actions)) {
- if (
- !this.keys.isSupersetOf(keys[action.CODE].keys) || activeAction && ('toggle' in keys[action.CODE] ?
- !('toggle' in keys[activeAction.CODE]) || keys[activeAction.CODE].keys.size >= keys[action.CODE].keys.size :
- !('toggle' in keys[activeAction.CODE]) && keys[activeAction.CODE].keys.size >= keys[action.CODE].keys.size)
- ) {
- if ('CLASS_ABLE' in action) {
- css.tag(action.CLASS_ABLE, false);
- }
-
- continue;
- }
-
- if (activeAction && 'CLASS_ABLE' in activeAction) {
- css.tag(activeAction.CLASS_ABLE, false);
- }
-
- activeAction = action;
- }
-
- if (activeAction === this.activeAction) {
- return;
- }
-
- if (activeAction) {
- if ('CLASS_ABLE' in activeAction) {
- css.tag(activeAction.CLASS_ABLE);
-
- css.tag(this.CLASS_ABLE);
-
- this.setActive(activeAction);
-
- return;
- }
-
- this.activeAction?.onInactive?.();
-
- activeAction.onActive();
-
- this.activeAction = activeAction;
- }
-
- css.tag(this.CLASS_ABLE, false);
-
- this.setActive(false);
- };
-
- this.stop = () => {
- css.tag(this.CLASS_ABLE, false);
-
- for (const action of Object.values(actions)) {
- if ('CLASS_ABLE' in action) {
- css.tag(action.CLASS_ABLE, false);
- }
- }
-
- this.setActive(false);
- };
-
- this.updateConfig = (() => {
- const rule = new css.Toggleable();
- const selector = `${css.getSelector(this.CLASS_ABLE)} #contentContainer.tp-yt-app-drawer[swipe-open]::after`
- + `,${css.getSelector(this.CLASS_ABLE)} #movie_player > .html5-video-container ~ :not(.${CLASS_VIEWFINDER})`;
-
- return () => {
- const {overlayRule} = $config.get().active;
-
- rule.remove();
-
- if (overlayRule) {
- rule.add(selector, overlayRule);
- }
- };
- })();
-
- $config.ready.then(() => {
- this.updateConfig();
- });
-
- // insertion order decides priority
- css.add(`${css.getSelector(this.CLASS_DRAGGING)} #movie_player`, ['cursor', 'grabbing']);
- css.add(`${css.getSelector(this.CLASS_ABLE)} #movie_player`, ['cursor', 'grab']);
- }();
-
- // ELEMENT CONTAINER SETUP
-
- const containers = new function () {
- for (const name of ['background', 'foreground', 'tracker']) {
- this[name] = document.createElement('div');
-
- this[name].classList.add(CLASS_VIEWFINDER);
- }
-
- // make an outline of the uncropped video
- css.add(`${css.getSelector(enabler.CLASS_ABLE)} #${this.foreground.id = 'viewfind-outlined'}`, ['outline', '1px solid white']);
-
- this.background.style.position = this.foreground.style.position = 'absolute';
- this.background.style.pointerEvents = this.foreground.style.pointerEvents = this.tracker.style.pointerEvents = 'none';
- this.tracker.style.height = this.tracker.style.width = '100%';
- }();
-
- // MODIFIERS
-
- class Cache {
- targets = [];
-
- constructor(...targets) {
- for (const source of targets) {
- this.targets.push({source});
- }
- }
-
- update(target) {
- return target.value !== (target.value = target.source.value);
- }
-
- isStale() {
- return this.targets.reduce((value, target) => value || this.update(target), false);
- }
- }
-
- class ConfigCache extends Cache {
- static id = 0;
-
- id = this.constructor.id;
-
- constructor(...targets) {
- super(...targets);
- }
-
- isStale() {
- if (this.id === (this.id = this.constructor.id)) {
- return super.isStale();
- }
-
- for (const target of this.targets) {
- target.value = target.source.value;
- }
-
- return true;
- }
- }
-
- const zoom = new function () {
- this.value = 1;
-
- const scaleRule = new css.Toggleable();
-
- this.reset = () => {
- this.value = 1;
-
- video.style.removeProperty('scale');
-
- scaleRule.remove();
- scaleRule.add(':root', [VAR_ZOOM, '1']);
- };
-
- this.apply = () => {
- video.style.setProperty('scale', `${this.value}`);
-
- scaleRule.remove();
- scaleRule.add(':root', [VAR_ZOOM, `${this.value}`]);
-
- delete actions.reset.restore;
- };
-
- this.getFit = (width = 1, height = 1) => {
- const [corner0, corner1] = getRotatedCorners(width * video.clientWidth, height * video.clientHeight);
-
- return 1 / Math.max(
- corner0.x / viewport.clientWidth, corner1.x / viewport.clientWidth,
- corner0.y / viewport.clientHeight, corner1.y / viewport.clientHeight,
- );
- };
-
- this.constrain = (() => {
- const limitGetters = {
- [LIMITS.static]: ({custom}) => custom,
- [LIMITS.fit]: ({frame}, glow) => {
- if (glow) {
- const base = glow.end - 1;
- const {scaled, unscaled} = glow.blur;
-
- return this.getFit(
- 1 + Math.max(0, base + Math.max(unscaled.x / video.clientWidth, scaled.x * this.value / video.clientWidth)) * frame,
- 1 + Math.max(0, base + Math.max(unscaled.y / video.clientHeight, scaled.y * this.value / video.clientHeight)) * frame,
- );
- }
-
- return this.getFit();
- },
- };
-
- return () => {
- const {zoomOutLimit, zoomInLimit, glow} = $config.get();
-
- if (zoomOutLimit.type !== 'None') {
- this.value = Math.max(limitGetters[zoomOutLimit.type](zoomOutLimit, glow), this.value);
- }
-
- if (zoomInLimit.type !== 'None') {
- this.value = Math.min(limitGetters[zoomInLimit.type](zoomInLimit, glow), this.value);
- }
-
- this.apply();
- };
- })();
- }();
-
- const rotation = new function () {
- this.value = PI_HALVES[0];
-
- this.reset = () => {
- this.value = PI_HALVES[0];
-
- video.style.removeProperty('rotate');
- };
-
- this.apply = () => {
- // Conversion from anticlockwise rotation from the x-axis to clockwise rotation from the y-axis
- video.style.setProperty('rotate', `${PI_HALVES[0] - this.value}rad`);
-
- delete actions.reset.restore;
- };
-
- // dissimilar from other constrain functions in that no effective limit is applied
- // -1.5π < rotation <= 0.5π
- // 0 <= 0.5π - rotation < 2π
- this.constrain = () => {
- this.value %= PI_HALVES[3];
-
- if (this.value > PI_HALVES[0]) {
- this.value -= PI_HALVES[3];
- } else if (this.value <= -PI_HALVES[2]) {
- this.value += PI_HALVES[3];
- }
-
- this.apply();
- };
- }();
-
- const position = new function () {
- this.x = this.y = 0;
-
- this.getValues = () => ({x: this.x, y: this.y});
-
- this.reset = () => {
- this.x = this.y = 0;
-
- video.style.removeProperty('translate');
- };
-
- this.apply = () => {
- video.style.setProperty('transform-origin', `${(0.5 + this.x) * 100}% ${(0.5 - this.y) * 100}%`);
- video.style.setProperty('translate', `${-this.x * 100}% ${this.y * 100}%`);
-
- delete actions.reset.restore;
- };
-
- this.constrain = (() => {
- const applyFrameValues = (lowCorner, highCorner, sub, main) => {
- this[sub] = Math.max(-lowCorner[sub], Math.min(highCorner[sub], this[sub]));
-
- const progress = (this[sub] + lowCorner[sub]) / (highCorner[sub] + lowCorner[sub]);
-
- if (this[main] < 0) {
- const bound = Number.isNaN(progress) ?
- -lowCorner[main] :
- (lowCorner[main] - highCorner[main]) * progress - lowCorner[main];
-
- this[main] = Math.max(this[main], bound);
- } else {
- const bound = Number.isNaN(progress) ?
- lowCorner[main] :
- (highCorner[main] - lowCorner[main]) * progress + lowCorner[main];
-
- this[main] = Math.min(this[main], bound);
- }
- };
-
- const applyFrame = (firstCorner, secondCorner, firstCornerAngle, secondCornerAngle) => {
- // The anti-clockwise angle from the first (top left) corner
- const midPointAngle = (getTheta(0, 0, this.x, this.y) + PI_HALVES[1] + firstCornerAngle) % PI_HALVES[3];
-
- if (midPointAngle % PI_HALVES[1] < secondCornerAngle) {
- // Frame is x-bound
- const [lowCorner, highCorner] = this.x >= 0 ? [firstCorner, secondCorner] : [secondCorner, firstCorner];
-
- applyFrameValues(lowCorner, highCorner, 'y', 'x');
- } else {
- // Frame is y-bound
- const [lowCorner, highCorner] = this.y >= 0 ? [firstCorner, secondCorner] : [secondCorner, firstCorner];
-
- applyFrameValues(lowCorner, highCorner, 'x', 'y');
- }
- };
-
- const getBoundApplyFrame = (() => {
- const getCorner = (first, second) => {
- if (zoom.value < first.z) {
- return {x: 0, y: 0};
- }
-
- if (zoom.value < second.z) {
- const progress = (1 / zoom.value - 1 / first.z) / (1 / second.z - 1 / first.z);
-
- return {
- x: Math.max(0, progress * (second.x - first.x) + first.x),
- y: Math.max(0, progress * (second.y - first.y) + first.y),
- };
- }
-
- return {
- x: Math.max(0, 0.5 - (0.5 - second.x) / (zoom.value / second.z)),
- y: Math.max(0, 0.5 - (0.5 - second.y) / (zoom.value / second.z)),
- };
- };
-
- return (first0, second0, first1, second1) => {
- const fFirstCorner = getCorner(first0, second0);
- const fSecondCorner = getCorner(first1, second1);
-
- const fFirstCornerAngle = getTheta(0, 0, fFirstCorner.x, fFirstCorner.y);
- const fSecondCornerAngle = fFirstCornerAngle + getTheta(0, 0, fSecondCorner.x, fSecondCorner.y);
-
- for (const [same, different] of [['x', 'y'], ['y', 'x']]) {
- if (fFirstCorner[same] === 0 && fSecondCorner[same] === 0) {
- if (fFirstCorner[different] > fSecondCorner[different]) {
- return applyFrame.bind(null, fFirstCorner, fFirstCorner, fFirstCornerAngle, fFirstCornerAngle);
- }
-
- return applyFrame.bind(null, fSecondCorner, fSecondCorner, fSecondCornerAngle, fSecondCornerAngle);
- }
- }
-
- return applyFrame.bind(null, fFirstCorner, fSecondCorner, fFirstCornerAngle, fSecondCornerAngle);
- };
- })();
-
- // https://math.stackexchange.com/questions/2223691/intersect-2-lines-at-the-same-ratio-through-a-point
- const snapZoom = (() => {
- const isAbove = (x, y, m, c) => m * x + c < y;
-
- const getPSecond = (low, high) => 1 - low / high;
- const getPFirst = (low, high, target) => (target - low) / (high - low);
-
- const getProgressed = (p, [fromX, fromY], [toX, toY]) => [p * (toX - fromX) + fromX, p * (toY - fromY) + fromY];
-
- const getFlipped = (first, second, flipX, flipY) => {
- const flippedFirst = [];
- const flippedSecond = [];
- const corner = [];
-
- if (flipX) {
- flippedFirst[0] = -first.x;
- flippedSecond[0] = -second.x;
- corner[0] = -0.5;
- } else {
- flippedFirst[0] = first.x;
- flippedSecond[0] = second.x;
- corner[0] = 0.5;
- }
-
- if (flipY) {
- flippedFirst[1] = -first.y;
- flippedSecond[1] = -second.y;
- corner[1] = -0.5;
- } else {
- flippedFirst[1] = first.y;
- flippedSecond[1] = second.y;
- corner[1] = 0.5;
- }
-
- return [flippedFirst, flippedSecond, corner];
- };
-
- const getIntersectPSecond = ([[g, e], [f, d]], [[k, i], [j, h]], doFlip) => {
- const x = Math.abs(position.x);
- const y = Math.abs(position.y);
-
- const a = d * j - d * k - j * e + e * k - h * f + h * g + i * f - i * g;
- const b = d * k - d * x - e * k + e * x + j * e - k * e - j * y + k * y - h * g + h * x + i * g - i * x - f * i + g * i + f * y - g * y;
- const c = k * e - e * x - k * y - g * i + i * x + g * y;
-
- return (doFlip ? -b - Math.sqrt(b * b - 4 * a * c) : -b + Math.sqrt(b * b - 4 * a * c)) / (2 * a);
- };
-
- const applyZoomPairSecond = ([z, ...pair], doFlip) => {
- const p = getIntersectPSecond(...pair, doFlip);
-
- if (p >= 0) {
- zoom.value = p >= 1 ? Number.MAX_SAFE_INTEGER : z / (1 - p);
-
- return true;
- }
-
- return false;
- };
-
- const applyZoomPairFirst = ([z0, z1, ...pair], doFlip) => {
- const p = getIntersectPSecond(...pair, doFlip);
-
- if (p >= 0) {
- zoom.value = p * (z1 - z0) + z0;
-
- return true;
- }
-
- return false;
- };
-
- return (first0, second0, first1, second1) => {
- const getPairings = (flipX0, flipY0, flipX1, flipY1) => {
- const [flippedFirst0, flippedSecond0, corner0] = getFlipped(first0, second0, flipX0, flipY0);
- const [flippedFirst1, flippedSecond1, corner1] = getFlipped(first1, second1, flipX1, flipY1);
-
- if (second0.z > second1.z) {
- const progressedHigh = getProgressed(getPSecond(second1.z, second0.z), flippedSecond1, corner1);
- const pairHigh = [
- second0.z,
- [flippedSecond0, corner0],
- [progressedHigh, corner1],
- ];
-
- if (second1.z > first0.z) {
- const progressedLow = getProgressed(getPFirst(first0.z, second0.z, second1.z), flippedFirst0, flippedSecond0);
-
- return [
- pairHigh,
- [
- second1.z,
- second0.z,
- [progressedLow, flippedSecond0],
- [flippedSecond1, progressedHigh],
- ],
- ];
- }
-
- const progressedLow = getProgressed(getPSecond(second1.z, first0.z), flippedSecond1, corner1);
-
- return [
- pairHigh,
- [
- first0.z,
- second0.z,
- [flippedFirst0, flippedSecond0],
- [progressedLow, progressedHigh],
- ],
- ];
- }
-
- const progressedHigh = getProgressed(getPSecond(second0.z, second1.z), flippedSecond0, corner0);
- const pairHigh = [
- second1.z,
- [progressedHigh, corner0],
- [flippedSecond1, corner1],
- ];
-
- if (second0.z > first1.z) {
- const progressedLow = getProgressed(getPFirst(first1.z, second1.z, second0.z), flippedFirst1, flippedSecond1);
-
- return [
- pairHigh,
- [
- second0.z,
- second1.z,
- [progressedLow, flippedSecond1],
- [flippedSecond0, progressedHigh],
- ],
- ];
- }
-
- const progressedLow = getProgressed(getPSecond(second0.z, first1.z), flippedSecond0, corner0);
-
- return [
- pairHigh,
- [
- first1.z,
- second1.z,
- [flippedFirst1, flippedSecond1],
- [progressedLow, progressedHigh],
- ],
- ];
- };
-
- const [pair0, pair1, doFlip = false] = (() => {
- const doInvert = position.x >= 0 === position.y < 0;
-
- if (doInvert) {
- const m = (second0.y - 0.5) / (second0.x - 0.5);
- const c = 0.5 - m * 0.5;
-
- if (isAbove(Math.abs(position.x), Math.abs(position.y), m, c)) {
- return [...getPairings(false, false, true, false), true];
- }
-
- return getPairings(false, false, false, true);
- }
-
- const m = (second1.y - 0.5) / (second1.x - 0.5);
- const c = 0.5 - m * 0.5;
-
- if (isAbove(Math.abs(position.x), Math.abs(position.y), m, c)) {
- return getPairings(true, false, false, false);
- }
-
- return [...getPairings(false, true, false, false), true];
- })();
-
- if (applyZoomPairSecond(pair0, doFlip) || applyZoomPairFirst(pair1, doFlip)) {
- return;
- }
-
- zoom.value = pair1[0];
- };
- })();
-
- const getZoomPoints = (mod) => {
- const [videoWidth, videoHeight] = (() => {
- const {glow} = $config.get();
-
- if (glow) {
- const {scaled, unscaled} = glow.blur;
-
- return [
- (video.clientWidth + Math.max(0, glow.end * video.clientWidth - video.clientWidth + Math.max(unscaled.x, scaled.x * zoom.value)) * mod) / 2,
- (video.clientHeight + Math.max(0, glow.end * video.clientHeight - video.clientHeight + Math.max(unscaled.y, scaled.y * zoom.value)) * mod) / 2,
- ];
- }
-
- return [video.clientWidth / 2, video.clientHeight / 2];
- })();
-
- const viewportWidth = viewport.clientWidth / 2;
- const viewportHeight = viewport.clientHeight / 2;
-
- const quadrant = Math.floor(rotation.value / PI_HALVES[0]) + 3;
-
- // the angle from 0,0 to the center of the video edge angled towards the viewport's upper-right corner
- const quadrantAngle = (() => {
- const angle = (rotation.value + PI_HALVES[3]) % PI_HALVES[0];
-
- return quadrant % 2 === 0 ? angle : PI_HALVES[0] - angle;
- })();
-
- const progress = quadrantAngle / PI_HALVES[0] * -2 + 1;
-
- const progressAngles = {
- base: Math.atan(progress * viewportWidth / viewportHeight),
- side: Math.atan(progress * viewportHeight / viewportWidth),
- };
-
- const progressCosines = {
- base: Math.cos(progressAngles.base),
- side: Math.cos(progressAngles.side),
- };
-
- const [baseCorners, sideCorners] = [
- [
- ((cornerAngle) => ({
- x: (videoWidth - videoHeight * Math.tan(cornerAngle)) / video.clientWidth,
- y: 0,
- z: viewportHeight / (progressCosines.base * Math.abs(videoHeight / Math.cos(cornerAngle))),
- }))(progressAngles.base + quadrantAngle),
-
- ((cornerAngle) => ({
- x: 0,
- y: (videoHeight - videoWidth * Math.tan(cornerAngle)) / video.clientHeight,
- z: viewportHeight / (progressCosines.base * Math.abs(videoWidth / Math.cos(cornerAngle))),
- }))(PI_HALVES[0] - progressAngles.base - quadrantAngle),
- ],
- [
- ((cornerAngle) => ({
- x: 0,
- y: (videoHeight - videoWidth * Math.tan(cornerAngle)) / video.clientHeight,
- z: viewportWidth / (progressCosines.side * Math.abs(videoWidth / Math.cos(cornerAngle))),
- }))(progressAngles.side + quadrantAngle),
-
- ((cornerAngle) => ({
- x: (videoWidth - videoHeight * Math.tan(cornerAngle)) / video.clientWidth,
- y: 0,
- z: viewportWidth / (progressCosines.side * Math.abs(videoHeight / Math.cos(cornerAngle))),
- }))(PI_HALVES[0] - progressAngles.side - quadrantAngle),
- ],
- // ascending order by zoom
- ].map(([xCorner, yCorner]) => xCorner.z < yCorner.z ? [xCorner, yCorner] : [yCorner, xCorner]);
-
- return quadrant % 2 === 1 ? [...baseCorners, ...sideCorners] : [...sideCorners, ...baseCorners];
- };
-
- const handlers = {
- [LIMITS.static]: ({custom: ratio}) => {
- const bound = 0.5 + (ratio - 0.5) / zoom.value;
-
- position.x = Math.max(-bound, Math.min(bound, position.x));
- position.y = Math.max(-bound, Math.min(bound, position.y));
- },
- [LIMITS.fit]: (() => {
- const cache = new ConfigCache(rotation, zoom);
-
- let boundApplyFrame;
-
- return ({frame}) => {
- if (cache.isStale()) {
- boundApplyFrame = getBoundApplyFrame(...getZoomPoints(frame));
- }
-
- boundApplyFrame();
- };
- })(),
- };
-
- const snapHandlers = {
- [LIMITS.fit]: (() => {
- const cache = new ConfigCache(rotation, zoom);
-
- let boundSnapZoom;
-
- return ({frame}) => {
- if (cache.isStale()) {
- boundSnapZoom = snapZoom.bind(null, ...getZoomPoints(frame));
- }
-
- boundSnapZoom();
-
- zoom.constrain();
- };
- })(),
- };
-
- return (doZoom = false) => {
- const {panLimit, snapPanLimit} = $config.get();
-
- if (doZoom) {
- snapHandlers[snapPanLimit.type]?.(snapPanLimit);
- }
-
- handlers[panLimit.type]?.(panLimit);
-
- this.apply();
- };
- })();
- }();
-
- const crop = new function () {
- this.top = this.right = this.bottom = this.left = 0;
-
- this.getValues = () => ({top: this.top, right: this.right, bottom: this.bottom, left: this.left});
-
- this.reveal = () => {
- this.top = this.right = this.bottom = this.left = 0;
-
- rule.remove();
- };
-
- this.reset = () => {
- this.reveal();
-
- actions.crop.reset();
- };
-
- const rule = new css.Toggleable();
-
- this.apply = () => {
- rule.remove();
- rule.add(
- `${SELECTOR_VIDEO}:not(.${this.CLASS_ABLE} *)`,
- ['clip-path', `inset(${this.top * 100}% ${this.right * 100}% ${this.bottom * 100}% ${this.left * 100}%)`],
- );
-
- delete actions.reset.restore;
-
- glow.handleViewChange();
- glow.reset();
- };
-
- this.getDimensions = (width = video.clientWidth, height = video.clientHeight) => [
- width * (1 - this.left - this.right),
- height * (1 - this.top - this.bottom),
- ];
- }();
-
- // FUNCTIONALITY
-
- const glow = (() => {
- const videoCanvas = new OffscreenCanvas(0, 0);
- const videoCtx = videoCanvas.getContext('2d', {alpha: false});
-
- const glowCanvas = document.createElement('canvas');
- const glowCtx = glowCanvas.getContext('2d', {alpha: false});
-
- glowCanvas.style.setProperty('position', 'absolute');
-
- class Sector {
- canvas = new OffscreenCanvas(0, 0);
- ctx = this.canvas.getContext('2d', {alpha: false});
-
- update(doFill) {
- if (doFill) {
- this.fill();
- } else {
- this.shift();
- this.take();
- }
-
- this.giveEdge();
-
- if (this.hasCorners) {
- this.giveCorners();
- }
- }
- }
-
- class Side extends Sector {
- setDimensions(doShiftRight, sWidth, sHeight, sx, sy, dx, dy, dWidth, dHeight) {
- this.canvas.width = sWidth;
- this.canvas.height = sHeight;
-
- this.shift = this.ctx.drawImage.bind(this.ctx, this.canvas, doShiftRight ? 1 : -1, 0);
-
- this.fill = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, 1, sHeight, 0, 0, sWidth, sHeight);
- this.take = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, 1, sHeight, doShiftRight ? 0 : sWidth - 1, 0, 1, sHeight);
-
- this.giveEdge = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);
-
- if (dy === 0) {
- this.hasCorners = false;
-
- return;
- }
-
- this.hasCorners = true;
-
- const giveCorner0 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, 1, dx, 0, dWidth, dy);
- const giveCorner1 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, sHeight - 1, sWidth, 1, dx, dy + dHeight, dWidth, dy);
-
- this.giveCorners = () => {
- giveCorner0();
- giveCorner1();
- };
- }
- }
-
- class Base extends Sector {
- setDimensions(doShiftDown, sWidth, sHeight, sx, sy, dx, dy, dWidth, dHeight) {
- this.canvas.width = sWidth;
- this.canvas.height = sHeight;
-
- this.shift = this.ctx.drawImage.bind(this.ctx, this.canvas, 0, doShiftDown ? 1 : -1);
-
- this.fill = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, sWidth, 1, 0, 0, sWidth, sHeight);
- this.take = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, sWidth, 1, 0, doShiftDown ? 0 : sHeight - 1, sWidth, 1);
-
- this.giveEdge = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);
-
- if (dx === 0) {
- this.hasCorners = false;
-
- return;
- }
-
- this.hasCorners = true;
-
- const giveCorner0 = glowCtx.drawImage.bind(glowCtx, this.canvas, 0, 0, 1, sHeight, 0, dy, dx, dHeight);
- const giveCorner1 = glowCtx.drawImage.bind(glowCtx, this.canvas, sWidth - 1, 0, 1, sHeight, dx + dWidth, dy, dx, dHeight);
-
- this.giveCorners = () => {
- giveCorner0();
- giveCorner1();
- };
- }
-
- setClipPath(points) {
- this.clipPath = new Path2D();
-
- this.clipPath.moveTo(...points[0]);
- this.clipPath.lineTo(...points[1]);
- this.clipPath.lineTo(...points[2]);
- this.clipPath.closePath();
- }
-
- update(doFill) {
- glowCtx.save();
-
- glowCtx.clip(this.clipPath);
-
- super.update(doFill);
-
- glowCtx.restore();
- }
- }
-
- const components = {
- left: new Side(),
- right: new Side(),
- top: new Base(),
- bottom: new Base(),
- };
-
- const setComponentDimensions = (sampleCount, size, isInset, doFlip) => {
- const [croppedWidth, croppedHeight] = crop.getDimensions();
- const halfCanvas = {x: Math.ceil(glowCanvas.width / 2), y: Math.ceil(glowCanvas.height / 2)};
- const halfVideo = {x: croppedWidth / 2, y: croppedHeight / 2};
- const dWidth = Math.ceil(Math.min(halfVideo.x, size));
- const dHeight = Math.ceil(Math.min(halfVideo.y, size));
- const [dWidthScale, dHeightScale, sideWidth, sideHeight] = isInset ?
- [0, 0, videoCanvas.width / croppedWidth * glowCanvas.width, videoCanvas.height / croppedHeight * glowCanvas.height] :
- [halfCanvas.x - halfVideo.x, halfCanvas.y - halfVideo.y, croppedWidth, croppedHeight];
-
- components.left.setDimensions(!doFlip, sampleCount, videoCanvas.height, 0, 0, 0, dHeightScale, dWidth, sideHeight);
-
- components.right.setDimensions(doFlip, sampleCount, videoCanvas.height, videoCanvas.width - 1, 0, glowCanvas.width - dWidth, dHeightScale, dWidth, sideHeight);
-
- components.top.setDimensions(!doFlip, videoCanvas.width, sampleCount, 0, 0, dWidthScale, 0, sideWidth, dHeight);
- components.top.setClipPath([[0, 0], [halfCanvas.x, halfCanvas.y], [glowCanvas.width, 0]]);
-
- components.bottom.setDimensions(doFlip, videoCanvas.width, sampleCount, 0, videoCanvas.height - 1, dWidthScale, glowCanvas.height - dHeight, sideWidth, dHeight);
- components.bottom.setClipPath([[0, glowCanvas.height], [halfCanvas.x, halfCanvas.y], [glowCanvas.width, glowCanvas.height]]);
- };
-
- class Instance {
- constructor() {
- const {filter, sampleCount, size, end, doFlip} = $config.get().glow;
-
- // Setup canvases
-
- glowCanvas.style.setProperty('filter', filter);
-
- [glowCanvas.width, glowCanvas.height] = crop.getDimensions().map((dimension) => dimension * end);
-
- glowCanvas.style.setProperty('left', `${crop.left * 100 + (1 - end) * (1 - crop.left - crop.right) * 50}%`);
- glowCanvas.style.setProperty('top', `${crop.top * 100 + (1 - end) * (1 - crop.top - crop.bottom) * 50}%`);
-
- [videoCanvas.width, videoCanvas.height] = crop.getDimensions(video.videoWidth, video.videoHeight);
-
- setComponentDimensions(sampleCount, size, end <= 1, doFlip);
-
- this.update(true);
- }
-
- update(doFill = false) {
- videoCtx.drawImage(
- video,
- crop.left * video.videoWidth,
- crop.top * video.videoHeight,
- video.videoWidth * (1 - crop.left - crop.right),
- video.videoHeight * (1 - crop.top - crop.bottom),
- 0,
- 0,
- videoCanvas.width,
- videoCanvas.height,
- );
-
- components.left.update(doFill);
- components.right.update(doFill);
- components.top.update(doFill);
- components.bottom.update(doFill);
- }
- }
-
- return new function () {
- const container = document.createElement('div');
-
- container.style.display = 'none';
-
- container.appendChild(glowCanvas);
- containers.background.appendChild(container);
-
- this.isHidden = false;
-
- let instance, startCopyLoop, stopCopyLoop;
-
- const play = () => {
- if (!video.paused && !this.isHidden && !enabler.isHidingGlow) {
- startCopyLoop?.();
- }
- };
-
- const fill = () => {
- if (!this.isHidden) {
- instance.update(true);
- }
- };
-
- const handleVisibilityChange = () => {
- if (document.hidden) {
- stopCopyLoop();
- } else {
- play();
- }
- };
-
- this.handleSizeChange = () => {
- instance = new Instance();
- };
-
- // set up pausing if glow isn't visible
- this.handleViewChange = (() => {
- const cache = new Cache(rotation, zoom);
-
- let corners;
-
- return (doForce = false) => {
- if (doForce || cache.isStale()) {
- corners = getRotatedCorners(viewport.clientWidth / 2 / zoom.value, viewport.clientHeight / 2 / zoom.value);
- }
-
- const videoX = position.x * video.clientWidth;
- const videoY = position.y * video.clientHeight;
-
- for (const corner of corners) {
- if (
- // unpause if the viewport extends more than 1 pixel beyond a video edge
- videoX + corner.x > (0.5 - crop.right) * video.clientWidth + 1
- || videoX - corner.x < (crop.left - 0.5) * video.clientWidth - 1
- || videoY + corner.y > (0.5 - crop.top) * video.clientHeight + 1
- || videoY - corner.y < (crop.bottom - 0.5) * video.clientHeight - 1
- ) {
- // fill if newly visible
- if (this.isHidden) {
- instance?.update(true);
- }
-
- this.isHidden = false;
-
- glowCanvas.style.removeProperty('visibility');
-
- play();
-
- return;
- }
- }
-
- this.isHidden = true;
-
- glowCanvas.style.visibility = 'hidden';
-
- stopCopyLoop?.();
- };
- })();
-
- const loop = {};
-
- this.start = () => {
- const config = $config.get().glow;
-
- if (!config) {
- return;
- }
-
- if (!enabler.isHidingGlow) {
- container.style.removeProperty('display');
- }
-
- // todo handle this?
- if (crop.left + crop.right >= 1 || crop.top + crop.bottom >= 1) {
- return;
- }
-
- let loopId = -1;
-
- if (loop.interval !== config.interval || loop.fps !== config.fps) {
- loop.interval = config.interval;
- loop.fps = config.fps;
- loop.wasSlow = false;
- loop.throttleCount = 0;
- }
-
- stopCopyLoop = () => ++loopId;
-
- instance = new Instance();
-
- startCopyLoop = async () => {
- const id = ++loopId;
-
- await new Promise((resolve) => {
- window.setTimeout(resolve, config.interval);
- });
-
- while (id === loopId) {
- const startTime = Date.now();
-
- instance.update();
-
- const delay = loop.interval - (Date.now() - startTime);
-
- if (delay <= 0) {
- if (loop.wasSlow) {
- loop.interval = 1000 / (loop.fps - ++loop.throttleCount);
- }
-
- loop.wasSlow = !loop.wasSlow;
-
- continue;
- }
-
- if (delay > 2 && loop.throttleCount > 0) {
- console.warn(`[${GM.info.script.name}] Glow update frequency reduced from ${loop.fps} hertz to ${loop.fps - loop.throttleCount} hertz due to poor performance.`);
-
- loop.fps -= loop.throttleCount;
-
- loop.throttleCount = 0;
- }
-
- loop.wasSlow = false;
-
- await new Promise((resolve) => {
- window.setTimeout(resolve, delay);
- });
- }
- };
-
- play();
-
- video.addEventListener('pause', stopCopyLoop);
- video.addEventListener('play', play);
- video.addEventListener('seeked', fill);
-
- document.addEventListener('visibilitychange', handleVisibilityChange);
- };
-
- const priorCrop = {};
-
- this.hide = () => {
- Object.assign(priorCrop, crop);
-
- stopCopyLoop?.();
-
- container.style.display = 'none';
- };
-
- this.show = () => {
- if (Object.entries(priorCrop).some(([edge, value]) => crop[edge] !== value)) {
- this.reset();
- } else {
- play();
- }
-
- container.style.removeProperty('display');
- };
-
- this.stop = () => {
- this.hide();
-
- video.removeEventListener('pause', stopCopyLoop);
- video.removeEventListener('play', play);
- video.removeEventListener('seeked', fill);
-
- document.removeEventListener('visibilitychange', handleVisibilityChange);
-
- startCopyLoop = undefined;
- stopCopyLoop = undefined;
- };
-
- this.reset = () => {
- this.stop();
-
- this.start();
- };
- }();
- })();
-
- const peek = (stop = false) => {
- const prior = {
- zoom: zoom.value,
- rotation: rotation.value,
- crop: crop.getValues(),
- position: position.getValues(),
- };
-
- position.reset();
- rotation.reset();
- zoom.reset();
- crop.reset();
-
- glow[stop ? 'stop' : 'reset']();
-
- return () => {
- zoom.value = prior.zoom;
- rotation.value = prior.rotation;
- Object.assign(position, prior.position);
- Object.assign(crop, prior.crop);
-
- actions.crop.set(prior.crop);
-
- position.apply();
- rotation.apply();
- zoom.apply();
- crop.apply();
- };
- };
-
- const actions = (() => {
- const drag = (event, clickCallback, moveCallback, target = video) => new Promise((resolve) => {
- event.stopImmediatePropagation();
- event.preventDefault();
-
- // window blur events don't fire if devtools is open
- stopDrag?.();
-
- target.setPointerCapture(event.pointerId);
-
- css.tag(enabler.CLASS_DRAGGING);
-
- const cancel = (event) => {
- event.stopImmediatePropagation();
- event.preventDefault();
- };
-
- document.addEventListener('click', cancel, true);
- document.addEventListener('dblclick', cancel, true);
-
- const clickDisallowListener = ({clientX, clientY}) => {
- const {clickCutoff} = $config.get();
- const distance = Math.abs(event.clientX - clientX) + Math.abs(event.clientY - clientY);
-
- if (distance >= clickCutoff) {
- target.removeEventListener('pointermove', clickDisallowListener);
- target.removeEventListener('pointerup', clickCallback);
- }
- };
-
- if (clickCallback) {
- target.addEventListener('pointermove', clickDisallowListener);
- target.addEventListener('pointerup', clickCallback, {once: true});
- }
-
- target.addEventListener('pointermove', moveCallback);
-
- stopDrag = () => {
- css.tag(enabler.CLASS_DRAGGING, false);
-
- target.removeEventListener('pointermove', moveCallback);
-
- if (clickCallback) {
- target.removeEventListener('pointermove', clickDisallowListener);
- target.removeEventListener('pointerup', clickCallback);
- }
-
- // delay removing listeners for events that happen after pointerup
- window.setTimeout(() => {
- document.removeEventListener('dblclick', cancel, true);
- document.removeEventListener('click', cancel, true);
- }, 0);
-
- window.removeEventListener('blur', stopDrag);
- target.removeEventListener('pointerup', stopDrag);
-
- target.releasePointerCapture(event.pointerId);
-
- stopDrag = undefined;
-
- enabler.handleChange();
-
- resolve();
- };
-
- window.addEventListener('blur', stopDrag);
- target.addEventListener('pointerup', stopDrag);
- });
-
- const getOnScroll = (() => {
- // https://stackoverflow.com/a/30134826
- const multipliers = [1, 40, 800];
-
- return (callback) => (event) => {
- event.stopImmediatePropagation();
- event.preventDefault();
-
- if (event.deltaY !== 0) {
- callback(event.deltaY * multipliers[event.deltaMode]);
- }
- };
- })();
-
- const addListeners = ({onMouseDown, onRightClick, onScroll}, doAdd = true) => {
- const property = `${doAdd ? 'add' : 'remove'}EventListener`;
-
- altTarget[property]('pointerdown', onMouseDown);
- altTarget[property]('contextmenu', onRightClick, true);
- altTarget[property]('wheel', onScroll);
- };
-
- return {
- crop: new function () {
- let top = 0, right = 0, bottom = 0, left = 0, handle;
-
- const values = {};
-
- Object.defineProperty(values, 'top', {get: () => top, set: (value) => top = value});
- Object.defineProperty(values, 'right', {get: () => right, set: (value) => right = value});
- Object.defineProperty(values, 'bottom', {get: () => bottom, set: (value) => bottom = value});
- Object.defineProperty(values, 'left', {get: () => left, set: (value) => left = value});
-
- class Button {
- // allowance for rounding errors
- static ALLOWANCE_HANDLE = 0.0001;
-
- static CLASS_HANDLE = 'viewfind-crop-handle';
- static CLASS_EDGES = {
- left: 'viewfind-crop-left',
- top: 'viewfind-crop-top',
- right: 'viewfind-crop-right',
- bottom: 'viewfind-crop-bottom',
- };
-
- static OPPOSITES = {
- left: 'right',
- right: 'left',
-
- top: 'bottom',
- bottom: 'top',
- };
-
- callbacks = [];
-
- element = document.createElement('div');
-
- constructor(...edges) {
- this.edges = edges;
-
- this.isHandle = true;
-
- this.element.style.position = 'absolute';
- this.element.style.pointerEvents = 'all';
-
- for (const edge of edges) {
- this.element.style[edge] = '0';
-
- this.element.classList.add(Button.CLASS_EDGES[edge]);
-
- this.element.style.setProperty(`border-${Button.OPPOSITES[edge]}-width`, '1px');
- }
-
- this.element.addEventListener('contextmenu', (event) => {
- event.stopPropagation();
- event.preventDefault();
-
- this.reset(false);
- });
-
- this.element.addEventListener('pointerdown', (() => {
- const clickListener = ({offsetX, offsetY, target}) => {
- this.set({
- width: (this.edges.includes('left') ? offsetX : target.clientWidth - offsetX) / video.clientWidth,
- height: (this.edges.includes('top') ? offsetY : target.clientHeight - offsetY) / video.clientHeight,
- }, false);
- };
-
- const getDragListener = (event, target) => {
- const getWidth = (() => {
- if (this.edges.includes('left')) {
- const position = this.element.clientWidth - event.offsetX;
-
- return ({offsetX}) => offsetX + position;
- }
-
- const position = target.offsetWidth + event.offsetX;
-
- return ({offsetX}) => position - offsetX;
- })();
-
- const getHeight = (() => {
- if (this.edges.includes('top')) {
- const position = this.element.clientHeight - event.offsetY;
-
- return ({offsetY}) => offsetY + position;
- }
-
- const position = target.offsetHeight + event.offsetY;
-
- return ({offsetY}) => position - offsetY;
- })();
-
- return (event) => {
- this.set({
- width: getWidth(event) / video.clientWidth,
- height: getHeight(event) / video.clientHeight,
- });
- };
- };
-
- return async (event) => {
- if (event.buttons === 1) {
- const target = this.element.parentElement;
-
- if (this.isHandle) {
- this.setPanel();
- }
-
- await drag(event, clickListener, getDragListener(event, target), target);
-
- this.updateCounterpart();
- }
- };
- })());
- }
-
- notify() {
- for (const callback of this.callbacks) {
- callback();
- }
- }
-
- set isHandle(value) {
- this._isHandle = value;
-
- this.element.classList[value ? 'add' : 'remove'](Button.CLASS_HANDLE);
- }
-
- get isHandle() {
- return this._isHandle;
- }
-
- reset() {
- this.isHandle = true;
-
- for (const edge of this.edges) {
- values[edge] = 0;
- }
- }
- }
-
- class EdgeButton extends Button {
- constructor(edge) {
- super(edge);
-
- this.edge = edge;
- }
-
- updateCounterpart() {
- if (this.counterpart.isHandle) {
- this.counterpart.setHandle();
- }
- }
-
- setCrop(value = 0) {
- values[this.edge] = value;
- }
-
- setPanel() {
- this.isHandle = false;
-
- this.setCrop(handle);
-
- this.setHandle();
- }
- }
-
- class SideButton extends EdgeButton {
- flow() {
- let size = 1;
-
- if (top <= Button.ALLOWANCE_HANDLE) {
- size -= handle;
-
- this.element.style.top = `${handle * 100}%`;
- } else {
- size -= top;
-
- this.element.style.top = `${top * 100}%`;
- }
-
- if (bottom <= Button.ALLOWANCE_HANDLE) {
- size -= handle;
- } else {
- size -= bottom;
- }
-
- this.element.style.height = `${Math.max(0, size * 100)}%`;
- }
-
- setBounds(counterpart, components) {
- this.counterpart = components[counterpart];
-
- components.top.callbacks.push(() => {
- this.flow();
- });
-
- components.bottom.callbacks.push(() => {
- this.flow();
- });
- }
-
- setHandle(doNotify = true) {
- this.element.style.width = `${Math.min(1 - values[this.counterpart.edge], handle) * 100}%`;
-
- if (doNotify) {
- this.notify();
- }
- }
-
- set({width}, doUpdateCounterpart = true) {
- if (this.isHandle !== (this.isHandle = width <= Button.ALLOWANCE_HANDLE)) {
- this.flow();
- }
-
- if (doUpdateCounterpart) {
- this.updateCounterpart();
- }
-
- if (this.isHandle) {
- this.setCrop();
-
- this.setHandle();
-
- return;
- }
-
- const size = Math.min(1 - values[this.counterpart.edge], width);
-
- this.setCrop(size);
-
- this.element.style.width = `${size * 100}%`;
-
- this.notify();
- }
-
- reset(isGeneral = true) {
- super.reset();
-
- if (isGeneral) {
- this.element.style.top = `${handle * 100}%`;
- this.element.style.height = `${(0.5 - handle) * 200}%`;
- this.element.style.width = `${handle * 100}%`;
-
- return;
- }
-
- this.flow();
-
- this.setHandle();
-
- this.updateCounterpart();
- }
- }
-
- class BaseButton extends EdgeButton {
- flow() {
- let size = 1;
-
- if (left <= Button.ALLOWANCE_HANDLE) {
- size -= handle;
-
- this.element.style.left = `${handle * 100}%`;
- } else {
- size -= left;
-
- this.element.style.left = `${left * 100}%`;
- }
-
- if (right <= Button.ALLOWANCE_HANDLE) {
- size -= handle;
- } else {
- size -= right;
- }
-
- this.element.style.width = `${Math.max(0, size) * 100}%`;
- }
-
- setBounds(counterpart, components) {
- this.counterpart = components[counterpart];
-
- components.left.callbacks.push(() => {
- this.flow();
- });
-
- components.right.callbacks.push(() => {
- this.flow();
- });
- }
-
- setHandle(doNotify = true) {
- this.element.style.height = `${Math.min(1 - values[this.counterpart.edge], handle) * 100}%`;
-
- if (doNotify) {
- this.notify();
- }
- }
-
- set({height}, doUpdateCounterpart = false) {
- if (this.isHandle !== (this.isHandle = height <= Button.ALLOWANCE_HANDLE)) {
- this.flow();
- }
-
- if (doUpdateCounterpart) {
- this.updateCounterpart();
- }
-
- if (this.isHandle) {
- this.setCrop();
-
- this.setHandle();
-
- return;
- }
-
- const size = Math.min(1 - values[this.counterpart.edge], height);
-
- this.setCrop(size);
-
- this.element.style.height = `${size * 100}%`;
-
- this.notify();
- }
-
- reset(isGeneral = true) {
- super.reset();
-
- if (isGeneral) {
- this.element.style.left = `${handle * 100}%`;
- this.element.style.width = `${(0.5 - handle) * 200}%`;
- this.element.style.height = `${handle * 100}%`;
-
- return;
- }
-
- this.flow();
-
- this.setHandle();
-
- this.updateCounterpart();
- }
- }
-
- class CornerButton extends Button {
- static CLASS_NAME = 'viewfind-crop-corner';
-
- constructor(sectors, ...edges) {
- super(...edges);
-
- this.element.classList.add(CornerButton.CLASS_NAME);
-
- this.sectors = sectors;
-
- for (const sector of sectors) {
- sector.callbacks.push(this.flow.bind(this));
- }
- }
-
- flow() {
- let isHandle = true;
-
- if (this.sectors[0].isHandle) {
- this.element.style.width = `${Math.min(1 - values[this.sectors[0].counterpart.edge], handle) * 100}%`;
- } else {
- this.element.style.width = `${values[this.edges[0]] * 100}%`;
-
- isHandle = false;
- }
-
- if (this.sectors[1].isHandle) {
- this.element.style.height = `${Math.min(1 - values[this.sectors[1].counterpart.edge], handle) * 100}%`;
- } else {
- this.element.style.height = `${values[this.edges[1]] * 100}%`;
-
- isHandle = false;
- }
-
- this.isHandle = isHandle;
- }
-
- updateCounterpart() {
- for (const sector of this.sectors) {
- sector.updateCounterpart();
- }
- }
-
- set(size) {
- for (const sector of this.sectors) {
- sector.set(size);
- }
- }
-
- reset(isGeneral = true) {
- this.isHandle = true;
-
- this.element.style.width = `${handle * 100}%`;
- this.element.style.height = `${handle * 100}%`;
-
- if (isGeneral) {
- return;
- }
-
- for (const sector of this.sectors) {
- sector.reset(false);
- }
- }
-
- setPanel() {
- for (const sector of this.sectors) {
- sector.setPanel();
- }
- }
- }
-
- this.CODE = 'crop';
-
- this.CLASS_ABLE = 'viewfind-action-able-crop';
-
- const container = document.createElement('div');
-
- // todo ditch the containers object
- container.style.width = container.style.height = 'inherit';
-
- containers.foreground.append(container);
-
- this.reset = () => {
- for (const component of Object.values(this.components)) {
- component.reset(true);
- }
- };
-
- this.onRightClick = (event) => {
- if (event.target.parentElement.id === container.id) {
- return;
- }
-
- event.stopPropagation();
- event.preventDefault();
-
- if (stopDrag) {
- return;
- }
-
- this.reset();
- };
-
- this.onScroll = getOnScroll((distance) => {
- const increment = distance * $config.get().speeds.crop / zoom.value;
-
- this.components.top.set({height: top + Math.min((1 - top - bottom) / 2, increment)});
- this.components.left.set({width: left + Math.min((1 - left - right) / 2, increment)});
-
- this.components.bottom.set({height: bottom + increment});
- this.components.right.set({width: right + increment});
- });
-
- this.onMouseDown = (() => {
- const getDragListener = () => {
- const multiplier = $config.get().multipliers.crop;
-
- const setX = ((right, left, change) => {
- const clamped = Math.max(-left, Math.min(right, change * multiplier / video.clientWidth));
-
- this.components.left.set({width: left + clamped});
- this.components.right.set({width: right - clamped});
- }).bind(undefined, right, left);
-
- const setY = ((top, bottom, change) => {
- const clamped = Math.max(-top, Math.min(bottom, change * multiplier / video.clientHeight));
-
- this.components.top.set({height: top + clamped});
-
- this.components.bottom.set({height: bottom - clamped});
- }).bind(undefined, top, bottom);
-
- let priorEvent;
-
- return ({offsetX, offsetY}) => {
- if (!priorEvent) {
- priorEvent = {offsetX, offsetY};
-
- return;
- }
-
- setX(offsetX - priorEvent.offsetX);
- setY(offsetY - priorEvent.offsetY);
- };
- };
-
- const clickListener = () => {
- zoom.value = zoom.getFit(1 - left - right, 1 - top - bottom);
-
- zoom.constrain();
-
- position.x = (left - right) / 2;
- position.y = (bottom - top) / 2;
-
- position.constrain();
- };
-
- return (event) => {
- if (event.buttons === 1) {
- drag(event, clickListener, getDragListener(), container);
- }
- };
- })();
-
- this.components = {
- top: new BaseButton('top'),
- right: new SideButton('right'),
- bottom: new BaseButton('bottom'),
- left: new SideButton('left'),
- };
-
- this.components.top.setBounds('bottom', this.components);
- this.components.right.setBounds('left', this.components);
- this.components.bottom.setBounds('top', this.components);
- this.components.left.setBounds('right', this.components);
-
- this.components.topLeft = new CornerButton([this.components.left, this.components.top], 'left', 'top');
- this.components.topRight = new CornerButton([this.components.right, this.components.top], 'right', 'top');
- this.components.bottomLeft = new CornerButton([this.components.left, this.components.bottom], 'left', 'bottom');
- this.components.bottomRight = new CornerButton([this.components.right, this.components.bottom], 'right', 'bottom');
-
- container.append(...Object.values(this.components).map(({element}) => element));
-
- this.set = ({top, right, bottom, left}) => {
- this.components.top.set({height: top});
- this.components.right.set({width: right});
- this.components.bottom.set({height: bottom});
- this.components.left.set({width: left});
- };
-
- this.onInactive = () => {
- addListeners(this, false);
-
- if (crop.left === left && crop.top === top && crop.right === right && crop.bottom === bottom) {
- return;
- }
-
- crop.left = left;
- crop.top = top;
- crop.right = right;
- crop.bottom = bottom;
-
- crop.apply();
- };
-
- this.onActive = () => {
- const config = $config.get().crop;
-
- handle = config.handle / Math.max(zoom.value, 1);
-
- for (const component of [this.components.top, this.components.bottom, this.components.left, this.components.right]) {
- if (component.isHandle) {
- component.setHandle();
- }
- }
-
- crop.reveal();
-
- addListeners(this);
-
- if (!enabler.isHidingGlow) {
- glow.handleViewChange();
-
- glow.reset();
- }
- };
-
- const draggingSelector = css.getSelector(enabler.CLASS_DRAGGING);
-
- this.updateConfig = (() => {
- const rule = new css.Toggleable();
-
- return () => {
- // set handle size
- for (const button of [this.components.left, this.components.top, this.components.right, this.components.bottom]) {
- if (button.isHandle) {
- button.setHandle();
- }
- }
-
- rule.remove();
-
- const {colour} = $config.get().crop;
- const {id} = container;
-
- rule.add(`#${id}>:hover.${Button.CLASS_HANDLE},#${id}>:not(.${Button.CLASS_HANDLE})`, ['background-color', colour.fill]);
- rule.add(`#${id}>*`, ['border-color', colour.border]);
- rule.add(`#${id}:not(${draggingSelector} *)>:not(:hover)`, ['filter', `drop-shadow(${colour.shadow} 0 0 1px)`]);
- };
- })();
-
- $config.ready.then(() => {
- this.updateConfig();
- });
-
- container.id = 'viewfind-crop-container';
-
- (() => {
- const {id} = container;
-
- css.add(`${css.getSelector(enabler.CLASS_DRAGGING)} #${id}`, ['cursor', 'grabbing']);
- css.add(`${css.getSelector(enabler.CLASS_ABLE)} #${id}`, ['cursor', 'grab']);
- css.add(`#${id}>:not(${draggingSelector} .${Button.CLASS_HANDLE})`, ['border-style', 'solid']);
- css.add(`${draggingSelector} #${id}>.${Button.CLASS_HANDLE}`, ['filter', 'none']);
-
- for (const [side, sideClass] of Object.entries(Button.CLASS_EDGES)) {
- css.add(
- `${draggingSelector} #${id}>.${sideClass}.${Button.CLASS_HANDLE}~.${sideClass}.${CornerButton.CLASS_NAME}`,
- [`border-${CornerButton.OPPOSITES[side]}-style`, 'none'],
- ['filter', 'none'],
- );
-
- // in fullscreen, 16:9 videos get an offsetLeft of 1px on my 16:9 monitor
- // I'm extending buttons by 1px so that they reach the edge of screens like mine at default zoom
- css.add(`#${id}>.${sideClass}`, [`margin-${side}`, '-1px'], [`padding-${side}`, '1px']);
- }
-
- css.add(`#${id}:not(.${this.CLASS_ABLE} *)`, ['display', 'none']);
- })();
- }(),
-
- pan: new function () {
- this.CODE = 'pan';
-
- this.CLASS_ABLE = 'viewfind-action-able-pan';
-
- this.onActive = () => {
- this.updateCrosshair();
-
- addListeners(this);
- };
-
- this.onInactive = () => {
- addListeners(this, false);
- };
-
- this.updateCrosshair = (() => {
- const getRoundedString = (number, decimal = 2) => {
- const raised = `${Math.round(number * Math.pow(10, decimal))}`.padStart(decimal + 1, '0');
-
- return `${raised.substr(0, raised.length - decimal)}.${raised.substr(raised.length - decimal)}`;
- };
-
- const getSigned = (ratio) => {
- const percent = Math.round(ratio * 100);
-
- if (percent <= 0) {
- return `${percent}`;
- }
-
- return `+${percent}`;
- };
-
- return () => {
- crosshair.text.innerText = `${getRoundedString(zoom.value)}×\n${getSigned(position.x)}%\n${getSigned(position.y)}%`;
- };
- })();
-
- this.onScroll = getOnScroll((distance) => {
- const increment = distance * $config.get().speeds.zoom;
-
- if (increment > 0) {
- zoom.value *= 1 + increment;
- } else {
- zoom.value /= 1 - increment;
- }
-
- zoom.constrain();
-
- position.constrain();
-
- this.updateCrosshair();
- });
-
- this.onRightClick = (event) => {
- event.stopImmediatePropagation();
- event.preventDefault();
-
- if (stopDrag) {
- return;
- }
-
- position.x = position.y = 0;
- zoom.value = 1;
-
- position.apply();
-
- zoom.constrain();
-
- this.updateCrosshair();
- };
-
- this.onMouseDown = (() => {
- const getDragListener = () => {
- const {multipliers} = $config.get();
-
- let priorEvent;
-
- const change = {x: 0, y: 0};
-
- return ({offsetX, offsetY}) => {
- if (priorEvent) {
- change.x = (priorEvent.offsetX + change.x - offsetX) * multipliers.pan;
- change.y = (priorEvent.offsetY - change.y - offsetY) * -multipliers.pan;
-
- position.x += change.x / video.clientWidth;
- position.y += change.y / video.clientHeight;
-
- position.constrain();
-
- this.updateCrosshair();
- }
-
- // events in firefox seem to lose their data after finishing propagation
- // so assigning the whole event doesn't work
- priorEvent = {offsetX, offsetY};
- };
- };
-
- const clickListener = (event) => {
- position.x = event.offsetX / video.clientWidth - 0.5;
- // Y increases moving down the page
- // I flip that to make trigonometry easier
- position.y = -event.offsetY / video.clientHeight + 0.5;
-
- position.constrain(true);
-
- this.updateCrosshair();
- };
-
- return (event) => {
- if (event.buttons === 1) {
- drag(event, clickListener, getDragListener());
- }
- };
- })();
- }(),
-
- rotate: new function () {
- this.CODE = 'rotate';
-
- this.CLASS_ABLE = 'viewfind-action-able-rotate';
-
- this.onActive = () => {
- this.updateCrosshair();
-
- addListeners(this);
- };
-
- this.onInactive = () => {
- addListeners(this, false);
- };
-
- this.updateCrosshair = () => {
- const angle = PI_HALVES[0] - rotation.value;
-
- crosshair.text.innerText = `${Math.floor((PI_HALVES[0] - rotation.value) / Math.PI * 180)}°\n≈${Math.round(angle / PI_HALVES[0]) % 4 * 90}°`;
- };
-
- this.onScroll = getOnScroll((distance) => {
- rotation.value += distance * $config.get().speeds.rotate;
-
- rotation.constrain();
-
- zoom.constrain();
- position.constrain();
-
- this.updateCrosshair();
- });
-
- this.onRightClick = (event) => {
- event.stopImmediatePropagation();
- event.preventDefault();
-
- if (stopDrag) {
- return;
- }
-
- rotation.value = PI_HALVES[0];
-
- rotation.apply();
-
- zoom.constrain();
- position.constrain();
-
- this.updateCrosshair();
- };
-
- this.onMouseDown = (() => {
- const getDragListener = () => {
- const {multipliers} = $config.get();
- const middleX = containers.tracker.clientWidth / 2;
- const middleY = containers.tracker.clientHeight / 2;
-
- const priorPosition = position.getValues();
- const priorZoom = zoom.value;
-
- let priorMouseTheta;
-
- return (event) => {
- const mouseTheta = getTheta(middleX, middleY, event.offsetX, event.offsetY);
-
- if (priorMouseTheta === undefined) {
- priorMouseTheta = mouseTheta;
-
- return;
- }
-
- position.x = priorPosition.x;
- position.y = priorPosition.y;
- zoom.value = priorZoom;
-
- rotation.value += (priorMouseTheta - mouseTheta) * multipliers.rotate;
-
- rotation.constrain();
-
- zoom.constrain();
- position.constrain();
-
- this.updateCrosshair();
-
- priorMouseTheta = mouseTheta;
- };
- };
-
- const clickListener = () => {
- rotation.value = Math.round(rotation.value / PI_HALVES[0]) * PI_HALVES[0];
-
- rotation.constrain();
-
- zoom.constrain();
- position.constrain();
-
- this.updateCrosshair();
- };
-
- return (event) => {
- if (event.buttons === 1) {
- drag(event, clickListener, getDragListener(), containers.tracker);
- }
- };
- })();
- }(),
-
- configure: new function () {
- this.CODE = 'config';
-
- this.onActive = async () => {
- await $config.edit();
-
- updateConfigs();
-
- viewport.focus();
-
- glow.reset();
-
- position.constrain();
- zoom.constrain();
- };
- }(),
-
- reset: new function () {
- this.CODE = 'reset';
-
- this.onActive = () => {
- if (this.restore) {
- this.restore();
- } else {
- this.restore = peek();
- }
- };
- }(),
- };
- })();
-
- const crosshair = new function () {
- this.container = document.createElement('div');
-
- this.lines = {
- horizontal: document.createElement('div'),
- vertical: document.createElement('div'),
- };
-
- this.text = document.createElement('div');
-
- const id = 'viewfind-crosshair';
-
- this.container.id = id;
- this.container.classList.add(CLASS_VIEWFINDER);
-
- css.add(`#${id}:not(${css.getSelector(actions.pan.CLASS_ABLE)} *):not(${css.getSelector(actions.rotate.CLASS_ABLE)} *)`, ['display', 'none']);
-
- this.lines.horizontal.style.position = this.lines.vertical.style.position = this.text.style.position = this.container.style.position = 'absolute';
-
- this.lines.horizontal.style.top = '50%';
- this.lines.horizontal.style.width = '100%';
-
- this.lines.vertical.style.left = '50%';
- this.lines.vertical.style.height = '100%';
-
- this.text.style.userSelect = 'none';
-
- this.container.style.top = '0';
- this.container.style.width = '100%';
- this.container.style.height = '100%';
- this.container.style.pointerEvents = 'none';
-
- this.container.append(this.lines.horizontal, this.lines.vertical);
-
- this.clip = () => {
- const {outer, inner, gap} = $config.get().crosshair;
-
- const thickness = Math.max(inner, outer);
-
- const halfWidth = viewport.clientWidth / 2;
- const halfHeight = viewport.clientHeight / 2;
- const halfGap = gap / 2;
-
- const startInner = (thickness - inner) / 2;
- const startOuter = (thickness - outer) / 2;
-
- const endInner = thickness - startInner;
- const endOuter = thickness - startOuter;
-
- this.lines.horizontal.style.clipPath = 'path(\''
- + `M0 ${startOuter}L${halfWidth - halfGap} ${startOuter}L${halfWidth - halfGap} ${startInner}L${halfWidth + halfGap} ${startInner}L${halfWidth + halfGap} ${startOuter}L${viewport.clientWidth} ${startOuter}`
- + `L${viewport.clientWidth} ${endOuter}L${halfWidth + halfGap} ${endOuter}L${halfWidth + halfGap} ${endInner}L${halfWidth - halfGap} ${endInner}L${halfWidth - halfGap} ${endOuter}L0 ${endOuter}`
- + 'Z\')';
-
- this.lines.vertical.style.clipPath = 'path(\''
- + `M${startOuter} 0L${startOuter} ${halfHeight - halfGap}L${startInner} ${halfHeight - halfGap}L${startInner} ${halfHeight + halfGap}L${startOuter} ${halfHeight + halfGap}L${startOuter} ${viewport.clientHeight}`
- + `L${endOuter} ${viewport.clientHeight}L${endOuter} ${halfHeight + halfGap}L${endInner} ${halfHeight + halfGap}L${endInner} ${halfHeight - halfGap}L${endOuter} ${halfHeight - halfGap}L${endOuter} 0`
- + 'Z\')';
- };
-
- this.updateConfig = (doClip = true) => {
- const {colour, outer, inner, text} = $config.get().crosshair;
- const thickness = Math.max(inner, outer);
-
- this.container.style.filter = `drop-shadow(${colour.shadow} 0 0 1px)`;
-
- this.lines.horizontal.style.translate = `0 -${thickness / 2}px`;
- this.lines.vertical.style.translate = `-${thickness / 2}px 0`;
-
- this.lines.horizontal.style.height = this.lines.vertical.style.width = `${thickness}px`;
-
- this.lines.horizontal.style.backgroundColor = this.lines.vertical.style.backgroundColor = colour.fill;
-
- if (text) {
- this.text.style.color = colour.fill;
-
- this.text.style.font = text.font;
- this.text.style.left = `${text.position.x}%`;
- this.text.style.top = `${text.position.y}%`;
- this.text.style.transform = `translate(${text.translate.x}%,${text.translate.y}%) translate(${text.offset.x}px,${text.offset.y}px)`;
- this.text.style.textAlign = text.align;
- this.text.style.lineHeight = text.height;
-
- this.container.append(this.text);
- } else {
- this.text.remove();
- }
-
- if (doClip) {
- this.clip();
- }
- };
-
- $config.ready.then(() => {
- this.updateConfig(false);
- });
- }();
-
- // ELEMENT CHANGE LISTENERS
-
- const observer = new function () {
- const onResolutionChange = () => {
- glow.handleSizeChange?.();
- };
-
- const styleObserver = new MutationObserver((() => {
- const properties = ['top', 'left', 'width', 'height', 'scale', 'rotate', 'translate', 'transform-origin'];
-
- let priorStyle;
-
- return () => {
- // mousemove events on video with ctrlKey=true trigger this but have no effect
- if (video.style.cssText === priorStyle) {
- return;
- }
-
- priorStyle = video.style.cssText;
-
- for (const property of properties) {
- containers.background.style[property] = video.style[property];
- containers.foreground.style[property] = video.style[property];
-
- // cinematics doesn't exist for embedded vids
- if (cinematics) {
- cinematics.style[property] = video.style[property];
- }
- }
-
- glow.handleViewChange();
- };
- })());
-
- const videoObserver = new ResizeObserver(() => {
- viewportAngles.set();
-
- glow.handleSizeChange?.();
- });
-
- const viewportObserver = new ResizeObserver(() => {
- viewportAngles.set();
-
- crosshair.clip();
- });
-
- this.start = () => {
- video.addEventListener('resize', onResolutionChange);
-
- styleObserver.observe(video, {attributes: true, attributeFilter: ['style']});
- viewportObserver.observe(viewport);
- videoObserver.observe(video);
-
- glow.handleViewChange();
- };
-
- this.stop = () => {
- video.removeEventListener('resize', onResolutionChange);
-
- styleObserver.disconnect();
- viewportObserver.disconnect();
- videoObserver.disconnect();
- };
- }();
-
- // NAVIGATION LISTENERS
-
- const stop = () => {
- if (stopped) {
- return;
- }
-
- stopped = true;
-
- enabler.stop();
-
- stopDrag?.();
-
- observer.stop();
-
- containers.background.remove();
- containers.foreground.remove();
- containers.tracker.remove();
- crosshair.container.remove();
-
- return peek(true);
- };
-
- const start = () => {
- if (!stopped || viewport.classList.contains('ad-showing')) {
- return;
- }
-
- stopped = false;
-
- observer.start();
-
- glow.start();
-
- viewport.append(containers.background, containers.foreground, containers.tracker, crosshair.container);
-
- // User may have a static minimum zoom greater than 1
- zoom.constrain();
-
- enabler.handleChange();
- };
-
- const updateConfigs = () => {
- ConfigCache.id++;
-
- enabler.updateConfig();
- actions.crop.updateConfig();
- crosshair.updateConfig();
- };
-
- // LISTENER ASSIGNMENTS
-
- // load & navigation
- (() => {
- const getNode = (node, selector, ...selectors) => {
- for (const child of node.children) {
- if (child.matches(selector)) {
- return selectors.length === 0 ? child : getNode(child, ...selectors);
- }
- }
-
- return null;
- };
-
- const init = async () => {
- if (unsafeWindow.ytplayer?.bootstrapPlayerContainer?.childElementCount > 0) {
- // wait for the video to be moved to ytd-app
- await new Promise((resolve) => {
- new MutationObserver((changes, observer) => {
- resolve();
-
- observer.disconnect();
- }).observe(unsafeWindow.ytplayer.bootstrapPlayerContainer, {childList: true});
- });
- }
-
- try {
- await $config.ready;
- } catch (error) {
- if (!$config.reset || !window.confirm(`${error.message}\n\nWould you like to erase your data?`)) {
- console.error(error);
-
- return;
- }
-
- await $config.reset();
-
- updateConfigs();
- }
-
- const pageManager = getNode(document.body, 'ytd-app', '#content', 'ytd-page-manager');
-
- if (pageManager) {
- const page = pageManager.getCurrentPage() ?? await new Promise((resolve) => {
- new MutationObserver(([{addedNodes: [page]}], observer) => {
- if (page) {
- resolve(page);
-
- observer.disconnect();
- }
- }).observe(pageManager, {childList: true});
- });
-
- await page.playerEl.getPlayerPromise();
-
- video = page.playerEl.querySelector(SELECTOR_VIDEO);
- cinematics = page.querySelector('#cinematics');
-
- // navigation to a new video
- new MutationObserver(() => {
- video.removeEventListener('play', startIfReady);
-
- power.off();
-
- // this callback can occur after metadata loads
- startIfReady();
- }).observe(page, {attributes: true, attributeFilter: ['video-id']});
-
- // navigation to a non-video page
- new MutationObserver(() => {
- if (video.src === '') {
- video.removeEventListener('play', startIfReady);
-
- power.off();
- }
- }).observe(video, {attributes: true, attributeFilter: ['src']});
- } else {
- video = document.body.querySelector(SELECTOR_VIDEO);
- }
-
- viewport = video.parentElement.parentElement;
- altTarget = viewport.parentElement;
-
- containers.foreground.style.zIndex = crosshair.container.style.zIndex = video.parentElement.computedStyleMap?.().get('z-index').value ?? 10;
- crosshair.clip();
- viewportAngles.set();
-
- const startIfReady = () => {
- if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
- start();
- }
- };
-
- const power = new function () {
- this.off = () => {
- delete this.wake;
-
- stop();
- };
-
- this.sleep = () => {
- this.wake ??= stop();
- };
- }();
-
- new MutationObserver((() => {
- return () => {
- // video end
- if (viewport.classList.contains('ended-mode')) {
- power.off();
-
- video.addEventListener('play', startIfReady, {once: true});
- // ad start
- } else if (viewport.classList.contains('ad-showing')) {
- power.sleep();
- }
- };
- })()).observe(viewport, {attributes: true, attributeFilter: ['class']});
-
- // glow initialisation requires video dimensions
- startIfReady();
-
- video.addEventListener('loadedmetadata', () => {
- if (viewport.classList.contains('ad-showing')) {
- return;
- }
-
- start();
-
- if (power.wake) {
- power.wake();
-
- delete power.wake;
- }
- });
- };
-
- if (!('ytPageType' in unsafeWindow) || unsafeWindow.ytPageType === 'watch') {
- init();
-
- return;
- }
-
- const initListener = ({detail: {newPageType}}) => {
- if (newPageType === 'ytd-watch-flexy') {
- init();
-
- document.body.removeEventListener('yt-page-type-changed', initListener);
- }
- };
-
- document.body.addEventListener('yt-page-type-changed', initListener);
- })();
-
- // keyboard state change
-
- document.addEventListener('keydown', ({code}) => {
- if (enabler.toggled) {
- enabler.keys[enabler.keys.has(code) ? 'delete' : 'add'](code);
-
- enabler.handleChange();
- } else if (!enabler.keys.has(code)) {
- enabler.keys.add(code);
-
- enabler.handleChange();
- }
- });
-
- document.addEventListener('keyup', ({code}) => {
- if (enabler.toggled) {
- return;
- }
-
- if (enabler.keys.has(code)) {
- enabler.keys.delete(code);
-
- enabler.handleChange();
- }
- });
-
- window.addEventListener('blur', () => {
- if (enabler.toggled) {
- stopDrag?.();
- } else {
- enabler.keys.clear();
-
- enabler.handleChange();
- }
- });
- })();