// ==UserScript==
// @name YouTube View Controls
// @version 0.0
// @description Zoom, rotate & crop YouTube videos.
// @author Callum Latham
// @namespace https://gf.qytechs.cn/users/696211-ctl2
// @license MIT
// @match *://www.youtube.com/*
// @match *://youtube.com/*
// @exclude *://www.youtube.com/embed/*
// @exclude *://youtube.com/embed/*
// @require https://update.gf.qytechs.cn/scripts/446506/1522829/%24Config.js
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// ==/UserScript==
/* global $Config */
// Don't run in frames (e.g. stream chat frame)
if (window.parent !== window) {
return;
}
const $config = new $Config(
'YTVC_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 = 0;
return () => `${id++}`;
})();
const getHideable = (() => {
const node = {
label: 'Enable?',
get: ({value: on}) => ({on}),
};
return (children, value = true, hideId = getHideId()) => ([
{...node, value, onUpdate: (value) => ({hide: {[hideId]: !value}})},
...children.map((child) => ({...child, hideId})),
]);
})();
const bgHideId = getHideId();
return {
get: (_, configs) => Object.assign(...configs),
children: [
{
label: 'Controls',
children: [
{
label: 'Key Combinations',
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 shift = navigator.userAgent.includes('Firefox') ? '\\' : 'Shift';
const seed = {
value: '',
listeners: {
keydown: (event) => {
switch (event.key) {
case 'Enter':
case 'Escape':
return;
}
event.preventDefault();
event.target.value = event.key;
event.target.dispatchEvent(new InputEvent('input'));
},
},
};
const getKeys = (children) => new Set(children.map(({value}) => value.toLowerCase()));
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', ['Control'], 'pan'],
['Rotate', [shift], 'rotate'],
['Crop', ['Control', shift], 'crop'],
].map(([label, keys, id]) => getNode(label, keys, ({children}) => ({id, keys: getKeys(children)}))),
],
},
getNode('Reset', ['x'], ({children}) => ({reset: {keys: getKeys(children)}})),
getNode('Configure', ['Alt', 'x'], ({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,
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 = true]) => ({
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: [
...[
['Zoom In Limit', 'zoomInLimit', 500],
['Zoom Out Limit', 'zoomOutLimit', 80],
['Pan Limit', 'panLimit', 50],
].map(([label, key, customValue, value = 'Custom', options = ['None', 'Custom', 'Frame'], hideId = getHideId()]) => ({
label,
get: (_, configs) => ({[key]: Object.assign(...configs)}),
children: [
{
label: 'Type',
value,
options,
get: ({value}) => ({type: options.indexOf(value)}),
onUpdate: (value) => ({hide: {[hideId]: value !== options[1]}}),
},
{
label: 'Limit (%)',
value: customValue,
predicate: (value) => value >= 0 || 'Limit must be positive',
inputAttributes: {min: 0},
get: ({value}) => ({value: value / 100}),
hideId,
},
],
})),
{
label: 'Peek On Button Hover?',
value: false,
get: ({value: peek}) => ({peek}),
},
{
label: 'Active Effects',
get: (_, configs) => ({active: Object.assign(...configs)}),
children: [
{
label: 'Pause Video?',
value: false,
get: ({value: pause}) => ({pause}),
},
{
label: 'Overlay Deactivation',
get: (_, configs) => {
const {on, hide} = Object.assign(...configs);
return {overlayRule: on ? ([hide ? 'display' : 'pointer-events', 'none']) : false};
},
children: getHideable([
{
label: 'Hide?',
value: false,
get: ({value: hide}) => ({hide}),
},
]),
},
{
label: 'Hide Background?',
value: false,
get: ({value: hideBg}) => ({hideBg}),
hideId: bgHideId,
},
],
},
],
},
{
label: 'Background',
get: (_, configs) => {
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 {
background: {
...config,
sampleCount: config.size,
interval: 1000 / fps,
fps,
},
};
}
return {
background: {
...config,
interval: 1000 / config.fps,
sampleCount,
},
};
},
children: getHideable([
{
label: 'Filter',
value: 'saturate(1.5) brightness(1.5) blur(25px)',
predicate: isCSSRule.bind(null, 'filter'),
get: ({value: filter}) => ({filter}),
},
{
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: flip}) => ({flip}),
},
],
},
{
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}),
},
], true, bgHideId),
},
{
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}) => ({handle}),
},
],
},
{
label: 'Crosshair',
get: (_, configs) => ({crosshair: Object.assign(...configs)}),
children: getHideable([
{
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}),
},
{
label: 'Text',
get: (_, configs) => {
const {translateX, translateY, ...config} = Object.assign(...configs);
return {
text: {
translate: {
x: translateX,
y: translateY,
},
...config,
},
};
},
children: getHideable([
{
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}),
},
]),
},
{
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 PI_HALVES = [Math.PI / 2, Math.PI, 3 * Math.PI / 2, Math.PI * 2];
const SELECTOR_ROOT = '#ytd-player > *';
const SELECTOR_VIEWPORT = '#movie_player';
const SELECTOR_VIDEO = 'video.video-stream.html5-main-video';
let video;
let altTarget;
let viewport;
let cinematics;
const viewportSectorAngles = {};
let videoAngle = PI_HALVES[0];
let zoom = 1;
const midPoint = {x: 0, y: 0};
const crop = {top: 0, right: 0, bottom: 0, left: 0};
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);
}
};
}();
// Reads user input to start & stop actions
const Enabler = new function() {
this.CLASS_ABLE = 'YTVC-action-able';
this.CLASS_DRAGGING = 'ytvc-action-dragging';
this.keys = new Set();
this.didPause = false;
this.isHidingBg = false;
this.setActive = (action) => {
const {active, keys} = $config.get();
if (active.hideBg && Boolean(action) !== this.isHidingBg) {
if (action) {
this.isHidingBg = true;
background.hide();
} else if (this.isHidingBg) {
this.isHidingBg = false;
background.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 (!video || stopDrag || video.ended) {
return;
}
const {keys} = $config.get();
let activeAction;
let keyCount = 0;
for (const action of Object.values(actions)) {
if (!this.keys.isSupersetOf(keys[action.CODE].keys) || keyCount >= 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;
keyCount = keys[action.CODE].keys.size;
}
if (!activeAction && this.toggled) {
css.tag(this.activeAction.CLASS_ABLE);
return;
}
if (activeAction === this.activeAction) {
if (!this.toggled) {
return;
}
activeAction = undefined;
}
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.updateConfig = (() => {
const rule = new css.Toggleable();
const selector = `${css.getSelector(this.CLASS_ABLE)} :where(.ytp-chrome-bottom,.ytp-chrome-top,.ytp-gradient-bottom,.ytp-gradient-top),` +
// I guess ::after doesn't work with :where
`${css.getSelector(this.CLASS_ABLE)} #contentContainer.tp-yt-app-drawer[swipe-open]::after`;
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)} ${SELECTOR_VIEWPORT}`, ['cursor', 'grabbing']);
css.add(`${css.getSelector(this.CLASS_ABLE)} ${SELECTOR_VIEWPORT}`, ['cursor', 'grab']);
}();
const containers = (() => {
const containers = Object.fromEntries(['background', 'foreground', 'tracker'].map((key) => [key, document.createElement('div')]));
containers.background.style.position = containers.foreground.style.position = 'absolute';
containers.background.style.pointerEvents = containers.foreground.style.pointerEvents = containers.tracker.style.pointerEvents = 'none';
containers.tracker.style.height = containers.tracker.style.width = '100%';
// make an outline of the uncropped video
const backgroundId = 'ytvc-container-background';
containers.background.id = backgroundId;
containers.background.style.boxSizing = 'border-box';
css.add(`${css.getSelector(Enabler.CLASS_ABLE)} #${backgroundId}`, ['border', '1px solid white']);
return containers;
})();
const setViewportAngles = () => {
viewportSectorAngles.side = getTheta(0, 0, viewport.clientWidth, viewport.clientHeight);
// equals `getTheta(0, 0, viewport.clientHeight, viewport.clientWidth)`
viewportSectorAngles.base = PI_HALVES[0] - viewportSectorAngles.side;
background.handleViewChange(true);
};
const getCroppedWidth = (width = video.clientWidth) => width * (1 - crop.left - crop.right);
const getCroppedHeight = (height = video.clientHeight) => height * (1 - crop.top - crop.bottom);
let stopDrag;
const handleMouseDown = (event, clickCallback, dragCallback, target = video) => new Promise((resolve) => {
event.stopImmediatePropagation();
event.preventDefault();
target.setPointerCapture(event.pointerId);
css.tag(Enabler.CLASS_DRAGGING);
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);
}
};
const doubleClickListener = (event) => {
event.stopImmediatePropagation();
};
target.addEventListener('pointermove', clickDisallowListener);
target.addEventListener('pointermove', dragCallback);
target.addEventListener('pointerup', clickCallback, {once: true});
viewport.parentElement.addEventListener('dblclick', doubleClickListener, true);
stopDrag = () => {
css.tag(Enabler.CLASS_DRAGGING, false);
target.removeEventListener('pointermove', clickDisallowListener);
target.removeEventListener('pointermove', dragCallback);
target.removeEventListener('pointerup', clickCallback);
// wait for a possible dblclick event to be dispatched
window.setTimeout(() => {
viewport.parentElement.removeEventListener('dblclick', doubleClickListener, true);
viewport.parentElement.removeEventListener('click', clickListener, 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 clickListener = (event) => {
event.stopImmediatePropagation();
event.preventDefault();
};
viewport.parentElement.addEventListener('click', clickListener, true);
});
const background = (() => {
const videoCanvas = new OffscreenCanvas(0, 0);
const videoCtx = videoCanvas.getContext('2d', {alpha: false});
const bgCanvas = document.createElement('canvas');
const bgCtx = bgCanvas.getContext('2d', {alpha: false});
bgCanvas.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 = bgCtx.drawImage.bind(bgCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);
if (dy === 0) {
this.hasCorners = false;
return;
}
this.hasCorners = true;
const giveCorner0 = bgCtx.drawImage.bind(bgCtx, this.canvas, 0, 0, sWidth, 1, dx, 0, dWidth, dy);
const giveCorner1 = bgCtx.drawImage.bind(bgCtx, 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 = bgCtx.drawImage.bind(bgCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight);
if (dx === 0) {
this.hasCorners = false;
return;
}
this.hasCorners = true;
const giveCorner0 = bgCtx.drawImage.bind(bgCtx, this.canvas, 0, 0, 1, sHeight, 0, dy, dx, dHeight);
const giveCorner1 = bgCtx.drawImage.bind(bgCtx, 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) {
bgCtx.save();
bgCtx.clip(this.clipPath);
super.update(doFill);
bgCtx.restore();
}
}
const components = {
left: new Side(),
right: new Side(),
top: new Base(),
bottom: new Base(),
};
const setComponentDimensions = (sampleCount, size, isInset, doFlip) => {
const croppedWidth = getCroppedWidth();
const croppedHeight = getCroppedHeight();
const halfCanvas = {x: Math.ceil(bgCanvas.width / 2), y: Math.ceil(bgCanvas.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 * bgCanvas.width, videoCanvas.height / croppedHeight * bgCanvas.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, bgCanvas.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], [bgCanvas.width, 0]]);
components.bottom.setDimensions(doFlip, videoCanvas.width, sampleCount, 0, videoCanvas.height - 1, dWidthScale, bgCanvas.height - dHeight, sideWidth, dHeight);
components.bottom.setClipPath([[0, bgCanvas.height], [halfCanvas.x, halfCanvas.y], [bgCanvas.width, bgCanvas.height]]);
};
class Instance {
constructor() {
const {filter, sampleCount, size, end, doFlip} = $config.get().background;
const endX = end * getCroppedWidth();
const endY = end * getCroppedHeight();
// Setup canvases
bgCanvas.style.setProperty('filter', filter);
bgCanvas.width = endX;
bgCanvas.height = endY;
bgCanvas.style.setProperty('left', `${crop.left * 100 + (1 - end) * (1 - crop.left - crop.right) * 50}%`);
bgCanvas.style.setProperty('top', `${crop.top * 100 + (1 - end) * (1 - crop.top - crop.bottom) * 50}%`);
videoCanvas.width = getCroppedWidth(video.videoWidth);
videoCanvas.height = getCroppedHeight(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(bgCanvas);
containers.background.appendChild(container);
this.isHidden = false;
let instance, startCopyLoop, stopCopyLoop;
const play = () => {
if (!video.paused && !this.isHidden && !Enabler.isHidingBg) {
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 background isn't visible
this.handleViewChange = (() => {
let priorAngle, priorZoom, cornerTop, cornerBottom;
return (doForce = false) => {
if (doForce || videoAngle !== priorAngle || zoom !== priorZoom) {
const viewportX = viewport.clientWidth / 2 / zoom;
const viewportY = viewport.clientHeight / 2 / zoom;
const angle = PI_HALVES[0] - videoAngle;
cornerTop = getGenericRotated(viewportX, viewportY, angle);
cornerBottom = getGenericRotated(viewportX, -viewportY, angle);
cornerTop.x = Math.abs(cornerTop.x);
cornerTop.y = Math.abs(cornerTop.y);
cornerBottom.x = Math.abs(cornerBottom.x);
cornerBottom.y = Math.abs(cornerBottom.y);
priorAngle = videoAngle;
priorZoom = zoom;
}
const videoX = Math.abs(midPoint.x) * video.clientWidth;
const videoY = Math.abs(midPoint.y) * video.clientHeight;
for (const corner of [cornerTop, cornerBottom]) {
if (
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;
bgCanvas.style.removeProperty('visibility');
play();
return;
}
}
this.isHidden = true;
bgCanvas.style.visibility = 'hidden';
stopCopyLoop?.();
};
})();
const loop = {};
this.start = () => {
const config = $config.get().background;
if (!config.on) {
return;
}
if (!Enabler.isHidingBg) {
container.style.removeProperty('display');
}
// todo handle this?
if (getCroppedWidth() === 0 || getCroppedHeight() === 0 || video.videoWidth === 0 || video.videoHeight === 0) {
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}] Background 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 resetMidPoint = () => {
midPoint.x = 0;
midPoint.y = 0;
video.style.removeProperty('translate');
};
const resetZoom = () => {
zoom = 1;
video.style.removeProperty('scale');
};
const resetRotation = () => {
videoAngle = PI_HALVES[0];
video.style.removeProperty('rotate');
ensureFramed();
};
const getFitContentZoom = (width = 1, height = 1) => {
const corner0 = getRotated(width, height, false);
const corner1 = getRotated(-width, height, false);
return 1 / Math.max(
Math.abs(corner0.x) / viewport.clientWidth, Math.abs(corner1.x) / viewport.clientWidth,
Math.abs(corner0.y) / viewport.clientHeight, Math.abs(corner1.y) / viewport.clientHeight,
);
};
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);
};
const getTheta = (fromX, fromY, toX, toY) => Math.atan2(toY - fromY, toX - fromX);
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, ratio = true) => {
// 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, videoAngle - PI_HALVES[0]);
return ratio ? {x: rotated.x / video.clientWidth, y: rotated.y / video.clientHeight} : rotated;
};
const applyZoom = (() => {
const getFramer = (() => {
let priorTheta, fitContentZoom;
return () => {
if (videoAngle !== priorTheta) {
priorTheta = videoAngle;
fitContentZoom = getFitContentZoom();
}
return fitContentZoom;
};
})();
const constrain = () => {
const {zoomOutLimit, zoomInLimit} = $config.get();
const framer = getFramer();
if (zoomOutLimit.type > 0) {
zoom = Math.max(zoomOutLimit.type === 1 ? zoomOutLimit.value : framer, zoom);
}
if (zoomInLimit.type > 0) {
zoom = Math.min(zoomInLimit.type === 1 ? zoomInLimit.value : framer, zoom);
}
};
return (doApply = true) => {
constrain();
if (doApply) {
video.style.setProperty('scale', `${zoom}`);
delete actions.reset.prior;
}
return zoom;
};
})();
const applyMidPoint = () => {
const {x, y} = getRotated(midPoint.x, midPoint.y);
video.style.setProperty('translate', `${-x * zoom * 100}% ${y * zoom * 100}%`);
delete actions.reset.prior;
};
const ensureFramed = (() => {
const applyFrameValues = (lowCorner, highCorner, sub, main) => {
midPoint[sub] = Math.max(-lowCorner[sub], Math.min(highCorner[sub], midPoint[sub]));
const progress = (midPoint[sub] + lowCorner[sub]) / (highCorner[sub] + lowCorner[sub]);
if (midPoint[main] < 0) {
const bound = Number.isNaN(progress) ?
-lowCorner[main] :
((lowCorner[main] - highCorner[main]) * progress - lowCorner[main]);
midPoint[main] = Math.max(midPoint[main], bound);
} else {
const bound = Number.isNaN(progress) ?
lowCorner[main] :
((highCorner[main] - lowCorner[main]) * progress + lowCorner[main]);
midPoint[main] = Math.min(midPoint[main], bound);
}
};
const applyFrame = (firstCorner, secondCorner, firstCornerAngle, secondCornerAngle) => {
// The anti-clockwise angle from the first (top left) corner
const midPointAngle = (getTheta(0, 0, midPoint.x, midPoint.y) + PI_HALVES[1] + firstCornerAngle) % PI_HALVES[3];
if ((midPointAngle % PI_HALVES[1]) < secondCornerAngle) {
// Frame is x-bound
const [lowCorner, highCorner] = midPoint.x >= 0 ? [firstCorner, secondCorner] : [secondCorner, firstCorner];
applyFrameValues(lowCorner, highCorner, 'y', 'x');
} else {
// Frame is y-bound
const [lowCorner, highCorner] = midPoint.y >= 0 ? [firstCorner, secondCorner] : [secondCorner, firstCorner];
applyFrameValues(lowCorner, highCorner, 'x', 'y');
}
};
const getBoundApplyFrame = (() => {
const getCorner = (first, second) => {
if (zoom < first.z) {
return {x: 0, y: 0};
}
if (zoom < second.z) {
const progress = (1 / zoom - 1 / first.z) / (1 / second.z - 1 / first.z);
return {
x: progress * (second.x - first.x) + first.x,
y: progress * (second.y - first.y) + first.y,
};
}
return {
x: Math.max(0, 0.5 - ((0.5 - second.x) / (zoom / second.z))),
y: Math.max(0, 0.5 - ((0.5 - second.y) / (zoom / 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);
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 = ([[from0X, from0Y], [to0X, to0Y]], [[from1X, from1Y], [to1X, to1Y]], doFlip) => {
const x = Math.abs(midPoint.x);
const y = Math.abs(midPoint.y);
const d = to0Y;
const e = from0Y;
const f = to0X;
const g = from0X;
const h = to1Y;
const i = from1Y;
const j = to1X;
const k = from1X;
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 = 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 = 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 = (midPoint.x >= 0) === (midPoint.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(midPoint.x), Math.abs(midPoint.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(midPoint.x), Math.abs(midPoint.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 = pair1[0];
};
})();
const getZoomBoundApplyFrameGetter = (() => () => {
const videoWidth = video.clientWidth / 2;
const videoHeight = video.clientHeight / 2;
const viewportWidth = viewport.clientWidth / 2;
const viewportHeight = viewport.clientHeight / 2;
const quadrant = Math.floor(videoAngle / PI_HALVES[0]) + 3;
const [xAngle, yAngle] = (() => {
const angle = (videoAngle + PI_HALVES[3]) % PI_HALVES[0];
return (quadrant % 2 === 0) ? [PI_HALVES[0] - angle, angle] : [angle, PI_HALVES[0] - angle];
})();
const progress = (xAngle / PI_HALVES[0]) * 2 - 1;
// equivalent:
// const progress = (yAngle / PI_HALVES[0]) * -2 + 1;
const cornerAZero = (() => {
const angleA = progress * viewportSectorAngles.side;
const angleB = PI_HALVES[0] - angleA - yAngle;
return {
// todo broken i guess :)
x: Math.abs((viewportWidth * Math.sin(angleA)) / (videoWidth * Math.cos(angleB))),
y: Math.abs((viewportWidth * Math.cos(angleB)) / (videoHeight * Math.cos(angleA))),
};
})();
const cornerBZero = (() => {
const angleA = progress * viewportSectorAngles.base;
const angleB = PI_HALVES[0] - angleA - yAngle;
return {
x: Math.abs((viewportHeight * Math.cos(angleA)) / (videoWidth * Math.cos(angleB))),
y: Math.abs((viewportHeight * Math.sin(angleB)) / (videoHeight * Math.cos(angleA))),
};
})();
const [cornerAX, cornerAY, cornerBX, cornerBY] = (() => {
const getCornerA = (() => {
const angleA = progress * viewportSectorAngles.side;
const angleB = PI_HALVES[0] - angleA - yAngle;
return (zoom) => {
const h = (viewportWidth / zoom) / Math.cos(angleA);
const xBound = Math.max(0, videoWidth - (Math.sin(angleB) * h));
const yBound = Math.max(0, videoHeight - (Math.cos(angleB) * h));
return {
x: xBound / video.clientWidth,
y: yBound / video.clientHeight,
};
};
})();
const getCornerB = (() => {
const angleA = progress * viewportSectorAngles.base;
const angleB = PI_HALVES[0] - angleA - yAngle;
return (zoom) => {
const h = (viewportHeight / zoom) / Math.cos(angleA);
const xBound = Math.max(0, videoWidth - (Math.cos(angleB) * h));
const yBound = Math.max(0, videoHeight - (Math.sin(angleB) * h));
return {
x: xBound / video.clientWidth,
y: yBound / video.clientHeight,
};
};
})();
return [
getCornerA(cornerAZero.x),
getCornerA(cornerAZero.y),
getCornerB(cornerBZero.x),
getCornerB(cornerBZero.y),
];
})();
const cornerAVars = cornerAZero.x < cornerAZero.y ?
[{z: cornerAZero.x, ...cornerAX}, {z: cornerAZero.y, ...cornerAY}] :
[{z: cornerAZero.y, ...cornerAY}, {z: cornerAZero.x, ...cornerAX}];
const cornerBVars = cornerBZero.x < cornerBZero.y ?
[{z: cornerBZero.x, ...cornerBX}, {z: cornerBZero.y, ...cornerBY}] :
[{z: cornerBZero.y, ...cornerBY}, {z: cornerBZero.x, ...cornerBX}];
if (quadrant % 2 === 0) {
return [
getBoundApplyFrame.bind(null, ...cornerAVars, ...cornerBVars),
snapZoom.bind(null, ...cornerAVars, ...cornerBVars),
];
}
return [
getBoundApplyFrame.bind(null, ...cornerBVars, ...cornerAVars),
snapZoom.bind(null, ...cornerBVars, ...cornerAVars),
];
})();
const handlers = [
() => {
applyMidPoint();
},
(doZoom, ratio) => {
if (doZoom) {
applyZoom();
}
const bound = 0.5 + (ratio - 0.5) / zoom;
midPoint.x = Math.max(-bound, Math.min(bound, midPoint.x));
midPoint.y = Math.max(-bound, Math.min(bound, midPoint.y));
applyMidPoint();
},
(() => {
let priorTheta, priorZoom, getZoomBoundApplyFrame, boundSnapZoom, boundApplyFrame;
return (doZoom) => {
if (videoAngle !== priorTheta) {
[getZoomBoundApplyFrame, boundSnapZoom] = getZoomBoundApplyFrameGetter();
boundApplyFrame = getZoomBoundApplyFrame();
priorTheta = videoAngle;
priorZoom = zoom;
} else if (!doZoom && zoom !== priorZoom) {
boundApplyFrame = getZoomBoundApplyFrame();
priorZoom = zoom;
}
if (doZoom) {
boundSnapZoom();
applyZoom();
ensureFramed();
return;
}
boundApplyFrame();
applyMidPoint();
};
})(),
];
return (doZoom = false) => {
const {panLimit} = $config.get();
return handlers[panLimit.type](doZoom, panLimit.value);
};
})();
const applyRotation = () => {
// Conversion from anticlockwise rotation from the x-axis to clockwise rotation from the y-axis
video.style.setProperty('rotate', `${PI_HALVES[0] - videoAngle}rad`);
delete actions.reset.prior;
};
const rotate = (change) => {
videoAngle = (videoAngle + change) % PI_HALVES[3];
if (videoAngle > PI_HALVES[0]) {
videoAngle -= PI_HALVES[3];
} else if (videoAngle <= -PI_HALVES[2]) {
videoAngle += PI_HALVES[3];
}
applyRotation();
// for fit-content zoom
applyZoom();
};
const actions = {
crop: new function() {
const currentCrop = {};
let handle;
class Button {
// allowance for rounding errors
static ALLOWANCE_HANDLE = 0.0001;
static CLASS_HANDLE = 'ytvc-crop-handle';
static CLASS_EDGES = {
left: 'ytvc-crop-left',
top: 'ytvc-crop-top',
right: 'ytvc-crop-right',
bottom: 'ytvc-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 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,
});
};
};
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);
};
return async (event) => {
if (event.buttons === 1) {
const target = this.element.parentElement;
await handleMouseDown(event, clickListener, getDragListener(event, target), target);
this.updateCounterpart();
}
};
})());
}
notify(property) {
for (const callback of this.callbacks) {
callback(this.element.style[property], property);
}
}
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) {
currentCrop[edge] = 0;
}
}
}
class EdgeButton extends Button {
constructor(edge) {
super(edge);
this.edge = edge;
}
updateCounterpart() {
if (this.counterpart.isHandle) {
this.counterpart.setHandle();
}
}
setCrop(value = 0) {
currentCrop[this.edge] = value;
}
}
class SideButton extends EdgeButton {
flow() {
const {top, bottom} = currentCrop;
let size = 100;
if (top <= Button.ALLOWANCE_HANDLE) {
size -= handle;
this.element.style.top = `${handle}%`;
} else {
size -= top * 100;
this.element.style.top = `${top * 100}%`;
}
if (bottom <= Button.ALLOWANCE_HANDLE) {
size -= handle;
} else {
size -= bottom * 100;
}
this.element.style.height = `${Math.max(0, size)}%`;
}
setBounds(counterpart, components) {
this.counterpart = components[counterpart];
components.top.callbacks.push(() => {
this.flow();
});
components.bottom.callbacks.push(() => {
this.flow();
});
}
notify() {
super.notify('width');
}
setHandle(doNotify = true) {
this.element.style.width = `${Math.min((1 - currentCrop[this.counterpart.edge]) * 100, handle)}%`;
if (doNotify) {
this.notify();
}
}
set({width}, doUpdateCounterpart = true) {
const wasHandle = this.isHandle;
this.isHandle = width <= Button.ALLOWANCE_HANDLE;
if (wasHandle !== this.isHandle) {
this.flow();
}
if (doUpdateCounterpart) {
this.updateCounterpart();
}
if (this.isHandle) {
this.setCrop();
this.setHandle();
return;
}
const size = Math.min(1 - currentCrop[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}%`;
this.element.style.height = `${100 - handle * 2}%`;
this.element.style.width = `${handle}%`;
return;
}
this.flow();
this.setHandle();
this.updateCounterpart();
}
}
class BaseButton extends EdgeButton {
flow() {
const {left, right} = currentCrop;
let size = 100;
if (left <= Button.ALLOWANCE_HANDLE) {
size -= handle;
this.element.style.left = `${handle}%`;
} else {
size -= left * 100;
this.element.style.left = `${left * 100}%`;
}
if (right <= Button.ALLOWANCE_HANDLE) {
size -= handle;
} else {
size -= right * 100;
}
this.element.style.width = `${Math.max(0, size)}%`;
}
setBounds(counterpart, components) {
this.counterpart = components[counterpart];
components.left.callbacks.push(() => {
this.flow();
});
components.right.callbacks.push(() => {
this.flow();
});
}
notify() {
super.notify('height');
}
setHandle(doNotify = true) {
this.element.style.height = `${Math.min((1 - currentCrop[this.counterpart.edge]) * 100, handle)}%`;
if (doNotify) {
this.notify();
}
}
set({height}, doUpdateCounterpart = false) {
const wasHandle = this.isHandle;
this.isHandle = height <= Button.ALLOWANCE_HANDLE;
if (wasHandle !== this.isHandle) {
this.flow();
}
if (doUpdateCounterpart) {
this.updateCounterpart();
}
if (this.isHandle) {
this.setCrop();
this.setHandle();
return;
}
const size = Math.min(1 - currentCrop[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}%`;
this.element.style.width = `${100 - handle * 2}%`;
this.element.style.height = `${handle}%`;
return;
}
this.flow();
this.setHandle();
this.updateCounterpart();
}
}
class CornerButton extends Button {
static CLASS_NAME = 'ytvc-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 - currentCrop[this.sectors[0].counterpart.edge]) * 100, handle)}%`;
} else {
this.element.style.width = `${currentCrop[this.edges[0]] * 100}%`;
isHandle = false;
}
if (this.sectors[1].isHandle) {
this.element.style.height = `${Math.min((1 - currentCrop[this.sectors[1].counterpart.edge]) * 100, handle)}%`;
} else {
this.element.style.height = `${currentCrop[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}%`;
this.element.style.height = `${handle}%`;
if (isGeneral) {
return;
}
for (const sector of this.sectors) {
sector.reset(false);
}
}
}
this.CODE = 'crop';
this.CLASS_ABLE = 'ytvc-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.onRightClick = (event) => {
if (event.target.parentElement.id === container.id) {
return;
}
event.stopPropagation();
event.preventDefault();
if (stopDrag) {
return;
}
for (const component of Object.values(this.components)) {
component.reset(true);
}
};
this.onScroll = (event) => {
const {speeds} = $config.get();
event.stopImmediatePropagation();
event.preventDefault();
if (event.deltaY === 0) {
return;
}
const increment = event.deltaY * speeds.crop / zoom;
const {top, left, right, bottom} = currentCrop;
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 {multipliers} = $config.get();
const {top, left, right, bottom} = currentCrop;
const clampX = (value) => Math.max(-left, Math.min(right, value));
const clampY = (value) => Math.max(-top, Math.min(bottom, value));
let priorEvent;
return ({offsetX, offsetY}) => {
if (!priorEvent) {
priorEvent = {offsetX, offsetY};
return;
}
const incrementX = clampX((priorEvent.offsetX - offsetX) * multipliers.crop / video.clientWidth);
const incrementY = clampY((priorEvent.offsetY - offsetY) * multipliers.crop / video.clientHeight);
this.components.top.set({height: top + incrementY});
this.components.left.set({width: left + incrementX});
this.components.bottom.set({height: bottom - incrementY});
this.components.right.set({width: right - incrementX});
};
};
const clickListener = () => {
const {top, left, right, bottom} = currentCrop;
zoom = getFitContentZoom(1 - left - right, 1 - top - bottom);
applyZoom();
midPoint.x = (left - right) / 2;
midPoint.y = (bottom - top) / 2;
ensureFramed();
};
return (event) => {
if (event.buttons === 1) {
handleMouseDown(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));
const cropRule = new css.Toggleable();
this.apply = ({top, left, right, bottom}) => {
cropRule.add(
`${SELECTOR_VIDEO}:not(.${this.CLASS_ABLE} *)`,
['clip-path', `inset(${top * 100}% ${right * 100}% ${bottom * 100}% ${left * 100}%)`],
);
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;
delete actions.reset.prior;
background.handleViewChange();
background.reset();
};
this.set = (crop) => {
this.apply(crop);
this.components.top.set({height: crop.top});
this.components.right.set({width: crop.right});
this.components.bottom.set({height: crop.bottom});
this.components.left.set({width: crop.left});
};
this.onInactive = () => {
this.apply(currentCrop);
addListeners(this, false);
};
this.onActive = () => {
const config = $config.get().crop;
handle = config.handle / Math.max(zoom, 1);
for (const component of [this.components.top, this.components.bottom, this.components.left, this.components.right]) {
if (component.isHandle) {
component.setHandle();
}
}
Object.assign(currentCrop, crop);
crop.top = crop.bottom = crop.left = crop.right = 0;
addListeners(this);
if (!Enabler.isHidingBg) {
background.handleViewChange();
background.reset();
}
};
this.stop = () => {
crop.top = crop.bottom = crop.left = crop.right = 0;
for (const component of Object.values(this.components)) {
component.reset(true);
}
cropRule.remove();
};
const draggingSelector = css.getSelector(Enabler.CLASS_DRAGGING);
this.updateConfig = (() => {
const rule = new css.Toggleable();
return () => {
Object.assign(currentCrop, crop);
// 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 = 'ytvc-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']);
// in fullscreen, 16:9 videos get an offsetLeft of 1px on my 16:9 monitor
// therefore I'm extending left-side buttons by 1px so that they still reach the edge of the screen
css.add(`#${id}>.${Button.CLASS_EDGES.left}`, ['margin-left', '-1px'], ['padding-left', '1px']);
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'],
);
}
css.add(`#${id}:not(.${this.CLASS_ABLE} *)`, ['display', 'none']);
})();
}(),
pan: new function() {
this.CODE = 'pan';
this.CLASS_ABLE = 'ytvc-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)}×\n${getSigned(midPoint.x)}%\n${getSigned(midPoint.y)}%`;
};
})();
this.onScroll = (event) => {
const {speeds} = $config.get();
event.stopImmediatePropagation();
event.preventDefault();
if (event.deltaY === 0) {
return;
}
const increment = event.deltaY * speeds.zoom;
if (increment > 0) {
zoom *= 1 + increment;
} else {
zoom /= 1 - increment;
}
applyZoom();
ensureFramed();
this.updateCrosshair();
};
this.onRightClick = (event) => {
event.stopImmediatePropagation();
event.preventDefault();
if (stopDrag) {
return;
}
resetMidPoint();
resetZoom();
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 = (offsetX - (priorEvent.offsetX + change.x)) * multipliers.pan;
change.y = (offsetY - (priorEvent.offsetY - change.y)) * -multipliers.pan;
midPoint.x += change.x / video.clientWidth;
midPoint.y += change.y / video.clientHeight;
ensureFramed();
this.updateCrosshair();
}
// events in firefox seem to lose their data after finishing propogation
// so assigning the whole event doesn't work
priorEvent = {offsetX, offsetY};
};
};
const clickListener = (event) => {
const position = {
x: (event.offsetX / video.clientWidth) - 0.5,
// Y increases moving down the page
// I flip that to make trigonometry easier
y: (-event.offsetY / video.clientHeight) + 0.5,
};
midPoint.x = position.x;
midPoint.y = position.y;
ensureFramed(true);
this.updateCrosshair();
};
return (event) => {
if (event.buttons === 1) {
handleMouseDown(event, clickListener, getDragListener());
}
};
})();
}(),
rotate: new function() {
this.CODE = 'rotate';
this.CLASS_ABLE = 'ytvc-action-able-rotate';
this.onActive = () => {
this.updateCrosshair();
addListeners(this);
};
this.onInactive = () => {
addListeners(this, false);
};
this.updateCrosshair = () => {
const angle = PI_HALVES[0] - videoAngle;
crosshair.text.innerText = `${Math.floor((PI_HALVES[0] - videoAngle) / Math.PI * 180)}°\n≈${Math.round(angle / PI_HALVES[0]) % 4 * 90}°`;
};
this.onScroll = (event) => {
const {speeds} = $config.get();
event.stopImmediatePropagation();
event.preventDefault();
rotate(speeds.rotate * event.deltaY);
ensureFramed();
this.updateCrosshair();
};
this.onRightClick = (event) => {
event.stopImmediatePropagation();
event.preventDefault();
if (stopDrag) {
return;
}
resetRotation();
this.updateCrosshair();
};
this.onMouseDown = (() => {
const getDragListener = () => {
const {multipliers} = $config.get();
const middleX = containers.tracker.clientWidth / 2;
const middleY = containers.tracker.clientHeight / 2;
const priorMidPoint = {...midPoint};
let priorMouseTheta;
return (event) => {
const mouseTheta = getTheta(middleX, middleY, event.offsetX, event.offsetY);
if (priorMouseTheta === undefined) {
priorMouseTheta = mouseTheta;
return;
}
rotate((mouseTheta - priorMouseTheta) * multipliers.rotate);
// only useful for the 'Frame' panLimit
// looks weird but it's probably useful
midPoint.x = priorMidPoint.x;
midPoint.y = priorMidPoint.y;
ensureFramed();
this.updateCrosshair();
priorMouseTheta = mouseTheta;
};
};
const clickListener = () => {
const theta = Math.abs(videoAngle) % PI_HALVES[0];
const progress = theta / PI_HALVES[0];
rotate(Math.sign(videoAngle) * (progress < 0.5 ? -theta : (PI_HALVES[0] - theta)));
ensureFramed();
this.updateCrosshair();
};
return (event) => {
if (event.buttons === 1) {
handleMouseDown(event, clickListener, getDragListener(), containers.tracker);
}
};
})();
}(),
configure: new function() {
this.CODE = 'config';
this.onActive = async () => {
kill();
await $config.edit();
updateConfigs();
viewport.focus();
background.reset();
ensureFramed();
applyZoom();
};
}(),
reset: new function() {
this.CODE = 'reset';
this.onActive = () => {
if (this.prior) {
zoom = this.prior.zoom;
videoAngle = this.prior.videoAngle;
Object.assign(midPoint, this.prior.midPoint);
actions.crop.set(this.prior.crop);
applyMidPoint();
applyRotation();
applyZoom();
return;
}
const prior = {
zoom,
videoAngle,
crop: {...crop},
midPoint: {...midPoint},
};
reset();
actions.crop.stop();
background.reset();
this.prior = prior;
};
}(),
};
const reset = () => {
resetMidPoint();
resetZoom();
resetRotation();
};
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 = 'ytvc-crosshair';
this.container.id = id;
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.text);
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.text.style.color = this.lines.horizontal.style.backgroundColor = this.lines.vertical.style.backgroundColor = 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;
if (doClip) {
this.clip();
}
};
$config.ready.then(() => {
this.updateConfig(false);
});
}();
const observer = new function() {
const onVideoEnd = () => {
stop();
video.addEventListener('play', () => {
start();
}, {once: true});
};
const onResolutionChange = () => {
background.handleSizeChange?.();
};
const styleObserver = new MutationObserver((() => {
const properties = ['top', 'left', 'width', 'height', 'scale', 'rotate', 'translate'];
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.style[property] = video.style[property];
}
background.handleViewChange();
};
})());
const videoObserver = new ResizeObserver(() => {
setViewportAngles();
background.handleSizeChange?.();
});
const viewportObserver = new ResizeObserver(() => {
setViewportAngles();
crosshair.clip();
});
this.start = () => {
video.addEventListener('ended', onVideoEnd);
video.addEventListener('resize', onResolutionChange);
styleObserver.observe(video, {attributes: true, attributeFilter: ['style']});
videoObserver.observe(video);
viewportObserver.observe(viewport);
background.handleViewChange();
};
this.stop = async (immediate) => {
if (!immediate) {
// delay stopping to reset observed elements
await new Promise((resolve) => {
window.setTimeout(resolve, 0);
});
}
video.removeEventListener('ended', onVideoEnd);
video.removeEventListener('resize', onResolutionChange);
styleObserver.disconnect();
videoObserver.disconnect();
viewportObserver.disconnect();
};
}();
const kill = () => {
stopDrag?.();
css.tag(Enabler.CLASS_ABLE, false);
for (const action of Object.values(actions)) {
if ('CLASS_ABLE' in action) {
css.tag(action.CLASS_ABLE, false);
}
}
Enabler.setActive(false);
};
const stop = (immediate = false) => {
kill();
observer.stop?.(immediate);
containers.background.remove();
containers.foreground.remove();
containers.tracker.remove();
crosshair.container.remove();
actions.crop.stop();
background.stop();
reset();
};
const start = () => {
observer.start();
background.start();
viewport.append(containers.background, containers.foreground, containers.tracker, crosshair.container);
// User may have a custom minimum zoom greater than 1
applyZoom();
Enabler.handleChange();
};
const updateConfigs = () => {
Enabler.updateConfig();
actions.crop.updateConfig();
crosshair.updateConfig();
};
document.body.addEventListener('yt-navigate-finish', async () => {
if (viewport) {
stop(true);
}
viewport = document.querySelector(SELECTOR_VIEWPORT);
if (!viewport) {
return;
}
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();
}
video = viewport.querySelector(SELECTOR_VIDEO);
altTarget = document.querySelector(SELECTOR_ROOT);
cinematics = document.querySelector('#cinematics');
// wait for video dimensions for background initialisation
if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
await new Promise((resolve) => {
video.addEventListener('loadedmetadata', resolve, {once: true});
});
}
containers.foreground.style.zIndex = crosshair.container.style.zIndex = video.parentElement.computedStyleMap?.().get('z-index').value ?? 10;
crosshair.clip();
setViewportAngles();
if (!video.ended) {
start();
}
});
// needs to be done after things are initialised
(() => {
const handleKeyChange = (key, isDown) => {
if (Enabler.keys.has(key) === isDown) {
return;
}
Enabler.keys[isDown ? 'add' : 'delete'](key);
Enabler.handleChange();
};
document.addEventListener('keydown', ({key}) => handleKeyChange(key.toLowerCase(), true));
document.addEventListener('keyup', ({key}) => handleKeyChange(key.toLowerCase(), false));
})();
window.addEventListener('blur', () => {
Enabler.keys.clear();
Enabler.handleChange();
});