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