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