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