您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Zoom, rotate & crop YouTube videos.
当前为
// ==UserScript== // @name YouTube View Controls // @version 0.0 // @description Zoom, rotate & crop YouTube videos. // @author Callum Latham // @namespace https://gf.qytechs.cn/users/696211-ctl2 // @license MIT // @match *://www.youtube.com/* // @match *://youtube.com/* // @exclude *://www.youtube.com/embed/* // @exclude *://youtube.com/embed/* // @require https://update.gf.qytechs.cn/scripts/446506/1518577/%24Config.js // @grant GM.setValue // @grant GM.getValue // @grant GM.deleteValue // ==/UserScript== /* global $Config */ // Don't run in frames (e.g. stream chat frame) if (window.parent !== window) { return; } const $config = new $Config( 'YTVZ_TREE', (() => { const keyValueListeners = { keydown: (event) => { switch (event.key) { case 'Enter': case 'Escape': return; } event.preventDefault(); event.target.value = event.key; event.target.dispatchEvent(new InputEvent('input')); }, }; const isCSSRule = (() => { const wrapper = document.createElement('style'); const regex = /\s/g; return (property, text) => { const ruleText = `${property}:${text};`; document.head.appendChild(wrapper); wrapper.sheet.insertRule(`:not(*){${ruleText}}`); const [{style: {cssText}}] = wrapper.sheet.cssRules; wrapper.remove(); return cssText.replaceAll(regex, '') === ruleText.replaceAll(regex, '') || `Must be a valid CSS ${property} rule`; }; })(); const getHideId = (() => { let id = 0; return () => `${id++}`; })(); const getHideable = (() => { const node = { label: 'Enable?', get: ({value: on}) => ({on}), }; return (children, value = true, hideId = getHideId()) => ([ {...node, value, onUpdate: (value) => ({hide: {[hideId]: !value}})}, ...children.map((child) => ({...child, hideId})), ]); })(); const bgHideId = getHideId(); return { get: (_, configs) => Object.assign(...configs), children: [ { label: 'Controls', children: [ { label: 'Key Combinations', descendantPredicate: (children) => { const isMatch = ({children: a}, {children: b}) => { if (a.length !== b.length) { return false; } return a.every(({value: keyA}) => b.some(({value: keyB}) => keyA === keyB)); }; for (let i = 1; i < children.length; ++i) { if (children.slice(i).some((child) => isMatch(children[i - 1], child))) { return 'Another action has this key combination'; } } return true; }, get: (_, configs) => ({keys: Object.assign(...configs)}), children: [ ['On / Off', ['x'], 'on'], ['Config', ['Alt', 'x'], 'config'], ['Pan / Zoom', ['Control'], 'pan'], ['Rotate', ['Shift'], 'rotate'], ['Crop', ['Control', 'Shift'], 'crop'], ].map(([label, values, key]) => ({ label, seed: { value: '', listeners: keyValueListeners, }, children: values.map((value) => ({value, listeners: keyValueListeners})), get: ({children}) => ({[key]: new Set(children.map(({value}) => value.toLowerCase()))}), })), }, { label: 'Scroll Speeds', get: (_, configs) => ({speeds: Object.assign(...configs)}), children: [ { label: 'Zoom', value: -100, get: ({value}) => ({zoom: value / 150000}), }, { label: 'Rotate', value: 100, get: ({value}) => ({rotate: value / 100000}), }, { label: 'Crop', value: -100, get: ({value}) => ({crop: value / 300000}), }, ], }, { label: 'Drag Inversions', get: (_, configs) => ({multipliers: Object.assign(...configs)}), children: [ ['Pan', 'pan'], ['Rotate', 'rotate'], ['Crop', 'crop'], ].map(([label, key, value = true]) => ({ label, value, get: ({value}) => ({[key]: value ? -1 : 1}), })), }, { label: 'Click Movement Allowance (px)', value: 2, predicate: (value) => value >= 0 || 'Allowance must be positive', inputAttributes: {min: 0}, get: ({value: clickCutoff}) => ({clickCutoff}), }, ], }, { label: 'Behaviour', children: [ ...[ ['Zoom In Limit', 'zoomInLimit', 500], ['Zoom Out Limit', 'zoomOutLimit', 80], ['Pan Limit', 'panLimit', 50], ].map(([label, key, customValue, value = 'Custom', options = ['None', 'Custom', 'Frame'], hideId = getHideId()]) => ({ label, get: (_, configs) => ({[key]: Object.assign(...configs)}), children: [ { label: 'Type', value, options, get: ({value}) => ({type: options.indexOf(value)}), onUpdate: (value) => ({hide: {[hideId]: value !== options[1]}}), }, { label: 'Limit (%)', value: customValue, predicate: (value) => value >= 0 || 'Limit must be positive', inputAttributes: {min: 0}, get: ({value}) => ({value: value / 100}), hideId, }, ], })), { label: 'Active Effects', get: (_, configs) => ({active: Object.assign(...configs)}), children: [ { label: 'Pause Video?', value: false, get: ({value: pause}) => ({pause}), }, { label: 'Overlay Deactivation', get: (_, configs) => { const {on, hide} = Object.assign(...configs); return {overlayRule: on ? ([hide ? 'display' : 'pointer-events', 'none']) : false}; }, children: getHideable([ { label: 'Hide?', value: false, get: ({value: hide}) => ({hide}), }, ]), }, { label: 'Hide Background?', value: false, get: ({value: hideBg}) => ({hideBg}), hideId: bgHideId, }, ], }, ], }, { label: 'Background', get: (_, configs) => { const {turnover, ...config} = Object.assign(...configs); const sampleCount = Math.floor(config.fps * turnover); // avoid taking more samples than there's space for if (sampleCount > config.size) { const fps = config.size / turnover; return { background: { ...config, sampleCount: config.size, interval: 1000 / fps, fps, }, }; } return { background: { ...config, interval: 1000 / config.fps, sampleCount, }, }; }, children: getHideable([ { label: 'Replace?', value: true, get: ({value: replace}) => ({replace}), }, { label: 'Filter', value: 'saturate(1.5) brightness(1.5) blur(25px)', predicate: isCSSRule.bind(null, 'filter'), get: ({value: filter}) => ({filter}), }, { label: 'Update', childPredicate: ([{value: fps}, {value: turnover}]) => (fps * turnover) >= 1 || `${turnover} second turnover cannot be achieved at ${fps} hertz`, children: [ { label: 'Frequency (Hz)', value: 15, predicate: (value) => { if (value > 144) { return 'Update frequency may not be above 144 hertz'; } return value >= 0 || 'Update frequency must be positive'; }, inputAttributes: {min: 0, max: 144}, get: ({value: fps}) => ({fps}), }, { label: 'Turnover Time (s)', value: 3, predicate: (value) => value >= 0 || 'Turnover time must be positive', inputAttributes: {min: 0}, get: ({value: turnover}) => ({turnover}), }, { label: 'Reverse?', value: false, get: ({value: flip}) => ({flip}), }, ], }, { label: 'Size (px)', value: 50, predicate: (value) => value >= 0 || 'Size must be positive', inputAttributes: {min: 0}, get: ({value}) => ({size: value}), }, { label: 'End Point (%)', value: 103, predicate: (value) => value >= 0 || 'End point must be positive', inputAttributes: {min: 0}, get: ({value}) => ({end: value / 100}), }, ], true, bgHideId), }, { label: 'Button', get: (_, configs) => ({button: Object.assign(...configs)}), children: getHideable([ { label: 'Peek On Hover?', value: false, get: ({value: peek}) => ({peek}), }, { label: 'Show Background Indicator?', value: true, get: ({value: indicate}) => ({indicate}), }, ]), }, { label: 'Interfaces', children: [ { label: 'Crop', get: (_, configs) => ({crop: Object.assign(...configs)}), children: [ { label: 'Colours', get: (_, configs) => ({colour: Object.assign(...configs)}), children: [ { label: 'Fill', get: (_, [colour, opacity]) => ({fill: `${colour}${opacity}`}), children: [ { label: 'Colour', value: '#808080', input: 'color', get: ({value}) => value, }, { label: 'Opacity (%)', value: 40, predicate: (value) => { if (value < 0) { return 'Opacity must be positive'; } return value <= 100 || 'Opacity may not exceed 100%'; }, inputAttributes: {min: 0, max: 100}, get: ({value}) => Math.round(255 * value / 100).toString(16), }, ], }, { label: 'Shadow', value: '#000000', input: 'color', get: ({value: shadow}) => ({shadow}), }, { label: 'Border', value: '#ffffff', input: 'color', get: ({value: border}) => ({border}), }, ], }, { label: 'Handle Size (%)', value: 6, predicate: (value) => { if (value < 0) { return 'Size must be positive'; } return value <= 50 || 'Size may not exceed 50%'; }, inputAttributes: {min: 0, max: 50}, get: ({value: handle}) => ({handle}), }, ], }, { label: 'Crosshair', get: (_, configs) => ({crosshair: Object.assign(...configs)}), children: getHideable([ { label: 'Outer Thickness (px)', value: 3, predicate: (value) => value >= 0 || 'Thickness must be positive', inputAttributes: {min: 0}, get: ({value: outer}) => ({outer}), }, { label: 'Inner Thickness (px)', value: 1, predicate: (value) => value >= 0 || 'Thickness must be positive', inputAttributes: {min: 0}, get: ({value: inner}) => ({inner}), }, { label: 'Inner Diameter (px)', value: 157, predicate: (value) => value >= 0 || 'Diameter must be positive', inputAttributes: {min: 0}, get: ({value: gap}) => ({gap}), }, { label: 'Text', get: (_, configs) => { const {translateX, translateY, ...config} = Object.assign(...configs); return { text: { translate: { x: translateX, y: translateY, }, ...config, }, }; }, children: getHideable([ { label: 'Font', value: '30px "Harlow Solid", cursive', predicate: isCSSRule.bind(null, 'font'), get: ({value: font}) => ({font}), }, { label: 'Position (%)', get: (_, configs) => ({position: Object.assign(...configs)}), children: ['x', 'y'].map((label) => ({ label, value: 0, predicate: (value) => Math.abs(value) <= 50 || 'Position must be on-screen', inputAttributes: {min: -50, max: 50}, get: ({value}) => ({[label]: value + 50}), })), }, { label: 'Offset (px)', get: (_, configs) => ({offset: Object.assign(...configs)}), children: [ { label: 'x', value: -6, get: ({value: x}) => ({x}), }, { label: 'y', value: -25, get: ({value: y}) => ({y}), }, ], }, (() => { const options = ['Left', 'Center', 'Right']; return { label: 'Alignment', value: options[2], options, get: ({value}) => ({align: value.toLowerCase(), translateX: options.indexOf(value) * -50}), }; })(), (() => { const options = ['Top', 'Middle', 'Bottom']; return { label: 'Baseline', value: options[0], options, get: ({value}) => ({translateY: options.indexOf(value) * -50}), }; })(), { label: 'Line height (%)', value: 90, predicate: (value) => value >= 0 || 'Height must be positive', inputAttributes: {min: 0}, get: ({value}) => ({height: value / 100}), }, ]), }, { label: 'Colours', get: (_, configs) => ({colour: Object.assign(...configs)}), children: [ { label: 'Fill', value: '#ffffff', input: 'color', get: ({value: fill}) => ({fill}), }, { label: 'Shadow', value: '#000000', input: 'color', get: ({value: shadow}) => ({shadow}), }, ], }, ]), }, ], }, ], }; })(), { headBase: '#c80000', headButtonExit: '#000000', borderHead: '#ffffff', borderTooltip: '#c80000', width: Math.min(90, screen.width / 16), height: 90, }, { zIndex: 10000, scrollbarColor: 'initial', }, ); const PI_HALVES = [Math.PI / 2, Math.PI, 3 * Math.PI / 2, Math.PI * 2]; const SELECTOR_ROOT = '#ytd-player > *'; const SELECTOR_VIEWPORT = '#movie_player'; const SELECTOR_VIDEO = 'video.video-stream.html5-main-video'; let isOn = false; let video; let altTarget; let viewport; const viewportSectorAngles = {}; let videoAngle = PI_HALVES[0]; let zoom = 1; const midPoint = {x: 0, y: 0}; const crop = {top: 0, right: 0, bottom: 0, left: 0}; const css = new function() { this.has = (name) => document.body.classList.contains(name); this.tag = (name, doAdd = true) => document.body.classList[doAdd ? 'add' : 'remove'](name); this.getSelector = (...classes) => `body.${classes.join('.')}`; const getSheet = () => { const element = document.createElement('style'); document.head.appendChild(element); return element.sheet; }; const getRuleString = (selector, ...declarations) => `${selector}{${declarations.map(([property, value]) => `${property}:${value};`).join('')}}`; this.add = function(...rule) { this.insertRule(getRuleString(...rule)); }.bind(getSheet()); this.Toggleable = class { static sheet = getSheet(); static active = []; static id = 0; static add(rule, id) { this.sheet.insertRule(rule, this.active.length); this.active.push(id); } static remove(id) { let index = this.active.indexOf(id); while (index >= 0) { this.sheet.deleteRule(index); this.active.splice(index, 1); index = this.active.indexOf(id); } } id = this.constructor.id++; add(...rule) { this.constructor.add(getRuleString(...rule), this.id); } remove() { this.constructor.remove(this.id); } }; }(); // Reads user input to start & stop actions const Enabler = new function() { this.CLASS_ABLE = 'ytvz-action-able'; this.CLASS_DRAGGING = 'ytvz-action-dragging'; this.keys = new Set(); this.didPause = false; this.isHidingBg = false; this.getAvailable = () => { const {keys} = $config.get(); for (const action of Object.values(actions)) { if (this.keys.isSupersetOf(keys[action.CODE])) { return action; } } return null; }; this.setActive = (action) => { const {active} = $config.get(); if (active.hideBg && Boolean(action) !== this.isHidingBg) { if (action) { this.isHidingBg = true; background.hide(); } else if (this.isHidingBg) { this.isHidingBg = false; background.show(); } button.setIndicator(); } this.activeAction?.onInactive(); if (action) { this.activeAction = action; action.onActive?.(); if (active.pause && !video.paused) { video.pause(); this.didPause = true; } return; } if (this.didPause) { video.play(); this.didPause = false; } this.activeAction = undefined; }; this.handleChange = () => { if (!video || stopDrag || video.ended) { return; } const {keys} = $config.get(); let activeAction = null; for (const action of Object.values(actions)) { if (this.keys.isSupersetOf(keys[action.CODE])) { if (!activeAction) { activeAction = action; continue; } if (keys[activeAction.CODE].size < keys[action.CODE].size) { css.tag(activeAction.CLASS_ABLE, false); activeAction = action; continue; } } css.tag(action.CLASS_ABLE, false); } if (activeAction === this.activeAction) { return; } css.tag(this.CLASS_ABLE, activeAction); if (activeAction) { css.tag(activeAction.CLASS_ABLE); } this.setActive(activeAction); }; this.updateConfig = (() => { const rule = new css.Toggleable(); const selector = `${css.getSelector(this.CLASS_ABLE)} :where(.ytp-chrome-bottom,.ytp-chrome-top,.ytp-gradient-bottom,.ytp-gradient-top),` + // I guess ::after doesn't work with :where `${css.getSelector(this.CLASS_ABLE)} #contentContainer.tp-yt-app-drawer[swipe-open]::after`; return () => { const {overlayRule} = $config.get().active; rule.remove(); if (overlayRule) { rule.add(selector, overlayRule); } }; })(); $config.ready.then(() => { this.updateConfig(); }); // insertion order decides priority css.add(`${css.getSelector(this.CLASS_DRAGGING)} ${SELECTOR_VIEWPORT}`, ['cursor', 'grabbing']); css.add(`${css.getSelector(this.CLASS_ABLE)} ${SELECTOR_VIEWPORT}`, ['cursor', 'grab']); }(); const containers = (() => { const containers = Object.fromEntries(['background', 'foreground', 'tracker'].map((key) => [key, document.createElement('div')])); containers.background.style.position = containers.foreground.style.position = 'absolute'; containers.background.style.pointerEvents = containers.foreground.style.pointerEvents = containers.tracker.style.pointerEvents = 'none'; containers.tracker.style.height = containers.tracker.style.width = '100%'; // make an outline of the uncropped video const backgroundId = 'ytvz-container-background'; containers.background.id = backgroundId; containers.background.style.boxSizing = 'border-box'; css.add(`${css.getSelector(Enabler.CLASS_ABLE)} #${backgroundId}`, ['border', '1px solid white']); return containers; })(); const setViewportAngles = () => { viewportSectorAngles.side = getTheta(0, 0, viewport.clientWidth, viewport.clientHeight); // equals `getTheta(0, 0, viewport.clientHeight, viewport.clientWidth)` viewportSectorAngles.base = PI_HALVES[0] - viewportSectorAngles.side; background.handleViewChange(true); }; const getCroppedWidth = (width = video.clientWidth) => width * (1 - crop.left - crop.right); const getCroppedHeight = (height = video.clientHeight) => height * (1 - crop.top - crop.bottom); let stopDrag; const handleMouseDown = (event, clickCallback, dragCallback, target = video) => new Promise((resolve) => { event.stopImmediatePropagation(); event.preventDefault(); target.setPointerCapture(event.pointerId); css.tag(Enabler.CLASS_DRAGGING); const clickDisallowListener = ({clientX, clientY}) => { const {clickCutoff} = $config.get(); const distance = Math.abs(event.clientX - clientX) + Math.abs(event.clientY - clientY); if (distance >= clickCutoff) { target.removeEventListener('pointermove', clickDisallowListener); target.removeEventListener('pointerup', clickCallback); } }; const doubleClickListener = (event) => { event.stopImmediatePropagation(); }; target.addEventListener('pointermove', clickDisallowListener); target.addEventListener('pointermove', dragCallback); target.addEventListener('pointerup', clickCallback, {once: true}); viewport.parentElement.addEventListener('dblclick', doubleClickListener, true); stopDrag = () => { css.tag(Enabler.CLASS_DRAGGING, false); target.removeEventListener('pointermove', clickDisallowListener); target.removeEventListener('pointermove', dragCallback); target.removeEventListener('pointerup', clickCallback); // wait for a possible dblclick event to be dispatched window.setTimeout(() => { viewport.parentElement.removeEventListener('dblclick', doubleClickListener, true); viewport.parentElement.removeEventListener('click', clickListener, true); }, 0); window.removeEventListener('blur', stopDrag); target.removeEventListener('pointerup', stopDrag); target.releasePointerCapture(event.pointerId); stopDrag = undefined; Enabler.handleChange(); resolve(); }; window.addEventListener('blur', stopDrag); target.addEventListener('pointerup', stopDrag); const clickListener = (event) => { event.stopImmediatePropagation(); event.preventDefault(); }; viewport.parentElement.addEventListener('click', clickListener, true); }); const background = (() => { const videoCanvas = new OffscreenCanvas(0, 0); const videoCtx = videoCanvas.getContext('2d', {alpha: false}); const bgCanvas = document.createElement('canvas'); const bgCtx = bgCanvas.getContext('2d', {alpha: false}); bgCanvas.style.setProperty('position', 'absolute'); class Sector { canvas = new OffscreenCanvas(0, 0); ctx = this.canvas.getContext('2d', {alpha: false}); update(doFill) { if (doFill) { this.fill(); } else { this.shift(); this.take(); } this.giveEdge(); if (this.hasCorners) { this.giveCorners(); } } } class Side extends Sector { setDimensions(doShiftRight, sWidth, sHeight, sx, sy, dx, dy, dWidth, dHeight) { this.canvas.width = sWidth; this.canvas.height = sHeight; this.shift = this.ctx.drawImage.bind(this.ctx, this.canvas, doShiftRight ? 1 : -1, 0); this.fill = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, 1, sHeight, 0, 0, sWidth, sHeight); this.take = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, 1, sHeight, doShiftRight ? 0 : sWidth - 1, 0, 1, sHeight); this.giveEdge = bgCtx.drawImage.bind(bgCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight); if (dy === 0) { this.hasCorners = false; return; } this.hasCorners = true; const giveCorner0 = bgCtx.drawImage.bind(bgCtx, this.canvas, 0, 0, sWidth, 1, dx, 0, dWidth, dy); const giveCorner1 = bgCtx.drawImage.bind(bgCtx, this.canvas, 0, sHeight - 1, sWidth, 1, dx, dy + dHeight, dWidth, dy); this.giveCorners = () => { giveCorner0(); giveCorner1(); }; } } class Base extends Sector { setDimensions(doShiftDown, sWidth, sHeight, sx, sy, dx, dy, dWidth, dHeight) { this.canvas.width = sWidth; this.canvas.height = sHeight; this.shift = this.ctx.drawImage.bind(this.ctx, this.canvas, 0, doShiftDown ? 1 : -1); this.fill = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, sWidth, 1, 0, 0, sWidth, sHeight); this.take = this.ctx.drawImage.bind(this.ctx, videoCanvas, sx, sy, sWidth, 1, 0, doShiftDown ? 0 : sHeight - 1, sWidth, 1); this.giveEdge = bgCtx.drawImage.bind(bgCtx, this.canvas, 0, 0, sWidth, sHeight, dx, dy, dWidth, dHeight); if (dx === 0) { this.hasCorners = false; return; } this.hasCorners = true; const giveCorner0 = bgCtx.drawImage.bind(bgCtx, this.canvas, 0, 0, 1, sHeight, 0, dy, dx, dHeight); const giveCorner1 = bgCtx.drawImage.bind(bgCtx, this.canvas, sWidth - 1, 0, 1, sHeight, dx + dWidth, dy, dx, dHeight); this.giveCorners = () => { giveCorner0(); giveCorner1(); }; } setClipPath(points) { this.clipPath = new Path2D(); this.clipPath.moveTo(...points[0]); this.clipPath.lineTo(...points[1]); this.clipPath.lineTo(...points[2]); this.clipPath.closePath(); } update(doFill) { bgCtx.save(); bgCtx.clip(this.clipPath); super.update(doFill); bgCtx.restore(); } } const components = { left: new Side(), right: new Side(), top: new Base(), bottom: new Base(), }; const setComponentDimensions = (sampleCount, size, isInset, doFlip) => { const croppedWidth = getCroppedWidth(); const croppedHeight = getCroppedHeight(); const halfCanvas = {x: Math.ceil(bgCanvas.width / 2), y: Math.ceil(bgCanvas.height / 2)}; const halfVideo = {x: croppedWidth / 2, y: croppedHeight / 2}; const dWidth = Math.ceil(Math.min(halfVideo.x, size)); const dHeight = Math.ceil(Math.min(halfVideo.y, size)); const [dWidthScale, dHeightScale, sideWidth, sideHeight] = isInset ? [0, 0, videoCanvas.width / croppedWidth * bgCanvas.width, videoCanvas.height / croppedHeight * bgCanvas.height] : [halfCanvas.x - halfVideo.x, halfCanvas.y - halfVideo.y, croppedWidth, croppedHeight]; components.left.setDimensions(!doFlip, sampleCount, videoCanvas.height, 0, 0, 0, dHeightScale, dWidth, sideHeight); components.right.setDimensions(doFlip, sampleCount, videoCanvas.height, videoCanvas.width - 1, 0, bgCanvas.width - dWidth, dHeightScale, dWidth, sideHeight); components.top.setDimensions(!doFlip, videoCanvas.width, sampleCount, 0, 0, dWidthScale, 0, sideWidth, dHeight); components.top.setClipPath([[0, 0], [halfCanvas.x, halfCanvas.y], [bgCanvas.width, 0]]); components.bottom.setDimensions(doFlip, videoCanvas.width, sampleCount, 0, videoCanvas.height - 1, dWidthScale, bgCanvas.height - dHeight, sideWidth, dHeight); components.bottom.setClipPath([[0, bgCanvas.height], [halfCanvas.x, halfCanvas.y], [bgCanvas.width, bgCanvas.height]]); }; class Instance { constructor() { const {filter, sampleCount, size, end, doFlip} = $config.get().background; const endX = end * getCroppedWidth(); const endY = end * getCroppedHeight(); // Setup canvases bgCanvas.style.setProperty('filter', filter); bgCanvas.width = endX; bgCanvas.height = endY; bgCanvas.style.setProperty('left', `${crop.left * 100 + (1 - end) * (1 - crop.left - crop.right) * 50}%`); bgCanvas.style.setProperty('top', `${crop.top * 100 + (1 - end) * (1 - crop.top - crop.bottom) * 50}%`); videoCanvas.width = getCroppedWidth(video.videoWidth); videoCanvas.height = getCroppedHeight(video.videoHeight); setComponentDimensions(sampleCount, size, end <= 1, doFlip); this.update(true); } update(doFill = false) { videoCtx.drawImage( video, crop.left * video.videoWidth, crop.top * video.videoHeight, video.videoWidth * (1 - crop.left - crop.right), video.videoHeight * (1 - crop.top - crop.bottom), 0, 0, videoCanvas.width, videoCanvas.height, ); components.left.update(doFill); components.right.update(doFill); components.top.update(doFill); components.bottom.update(doFill); } } return new function() { const container = document.createElement('div'); container.style.display = 'none'; container.appendChild(bgCanvas); containers.background.appendChild(container); const CLASS_CINOMATICS = 'ytvz-background-hide-cinematics'; css.add(`${css.getSelector(CLASS_CINOMATICS)} #cinematics`, ['display', 'none']); this.isHidden = false; let instance, startCopyLoop, stopCopyLoop; const play = () => { if (!video.paused && !this.isHidden && !Enabler.isHidingBg) { startCopyLoop?.(); } }; const fill = () => { if (!this.isHidden) { instance.update(true); } }; const handleVisibilityChange = () => { if (document.hidden) { stopCopyLoop(); } else { play(); } }; this.handleSizeChange = () => { instance = new Instance(); }; // set up pausing if background isn't visible this.handleViewChange = (() => { let priorAngle, priorZoom, cornerTop, cornerBottom; return (doForce = false) => { if (doForce || videoAngle !== priorAngle || zoom !== priorZoom) { const viewportX = viewport.clientWidth / 2 / zoom; const viewportY = viewport.clientHeight / 2 / zoom; const angle = PI_HALVES[0] - videoAngle; cornerTop = getGenericRotated(viewportX, viewportY, angle); cornerBottom = getGenericRotated(viewportX, -viewportY, angle); cornerTop.x = Math.abs(cornerTop.x); cornerTop.y = Math.abs(cornerTop.y); cornerBottom.x = Math.abs(cornerBottom.x); cornerBottom.y = Math.abs(cornerBottom.y); priorAngle = videoAngle; priorZoom = zoom; } const videoX = Math.abs(midPoint.x) * video.clientWidth; const videoY = Math.abs(midPoint.y) * video.clientHeight; for (const corner of [cornerTop, cornerBottom]) { if ( videoX + corner.x > (0.5 - crop.right) * video.clientWidth + 1 || videoX - corner.x < (crop.left - 0.5) * video.clientWidth - 1 || videoY + corner.y > (0.5 - crop.top) * video.clientHeight + 1 || videoY - corner.y < (crop.bottom - 0.5) * video.clientHeight - 1 ) { // fill if newly visible if (this.isHidden) { instance?.update(true); } this.isHidden = false; button.setIndicator(); bgCanvas.style.removeProperty('visibility'); play(); return; } } this.isHidden = true; button.setIndicator(); bgCanvas.style.visibility = 'hidden'; stopCopyLoop?.(); }; })(); const loop = {}; this.start = () => { const config = $config.get().background; if (!config.on || getCroppedWidth() === 0 || getCroppedHeight() === 0) { return; } if (!Enabler.isHidingBg) { container.style.removeProperty('display'); } if (config.replace) { css.tag(CLASS_CINOMATICS); } let loopId = -1; if (loop.interval !== config.interval || loop.fps !== config.fps) { loop.interval = config.interval; loop.fps = config.fps; loop.wasSlow = false; loop.throttleCount = 0; } stopCopyLoop = () => ++loopId; instance = new Instance(); startCopyLoop = async () => { const id = ++loopId; await new Promise((resolve) => { window.setTimeout(resolve, config.interval); }); while (id === loopId) { const startTime = Date.now(); instance.update(); const delay = loop.interval - (Date.now() - startTime); if (delay <= 0) { if (loop.wasSlow) { loop.interval = 1000 / (loop.fps - ++loop.throttleCount); } loop.wasSlow = !loop.wasSlow; continue; } if (delay > 2 && loop.throttleCount > 0) { console.warn(`[${GM.info.script.name}] Background update frequency reduced from ${loop.fps} hertz to ${loop.fps - loop.throttleCount} hertz due to poor performance.`); loop.fps -= loop.throttleCount; loop.throttleCount = 0; } loop.wasSlow = false; await new Promise((resolve) => { window.setTimeout(resolve, delay); }); } }; play(); video.addEventListener('pause', stopCopyLoop); video.addEventListener('play', play); video.addEventListener('seeked', fill); document.addEventListener('visibilitychange', handleVisibilityChange); }; const priorCrop = {}; this.hide = () => { Object.assign(priorCrop, crop); stopCopyLoop?.(); container.style.display = 'none'; }; this.show = () => { if (Object.entries(priorCrop).some(([edge, value]) => crop[edge] !== value)) { this.reset(); } else { play(); } container.style.removeProperty('display'); }; this.stop = () => { this.hide(); css.tag(CLASS_CINOMATICS, false); video.removeEventListener('pause', stopCopyLoop); video.removeEventListener('play', play); video.removeEventListener('seeked', fill); document.removeEventListener('visibilitychange', handleVisibilityChange); startCopyLoop = undefined; stopCopyLoop = undefined; }; this.reset = () => { this.stop(); this.start(); }; }(); })(); const resetMidPoint = () => { midPoint.x = 0; midPoint.y = 0; applyMidPoint(); }; const resetZoom = () => { zoom = 1; applyZoom(); }; const resetRotation = () => { videoAngle = PI_HALVES[0]; applyRotation(); ensureFramed(); }; const getFitContentZoom = (width = 1, height = 1) => { const corner0 = getRotated(width, height, false); const corner1 = getRotated(-width, height, false); return 1 / Math.max( Math.abs(corner0.x) / viewport.clientWidth, Math.abs(corner1.x) / viewport.clientWidth, Math.abs(corner0.y) / viewport.clientHeight, Math.abs(corner1.y) / viewport.clientHeight, ); }; const addListeners = ({onMouseDown, onRightClick, onScroll}, doAdd = true) => { const property = `${doAdd ? 'add' : 'remove'}EventListener`; altTarget[property]('pointerdown', onMouseDown); altTarget[property]('contextmenu', onRightClick, true); altTarget[property]('wheel', onScroll); }; const getTheta = (fromX, fromY, toX, toY) => Math.atan2(toY - fromY, toX - fromX); const getGenericRotated = (x, y, angle) => { const radius = Math.sqrt((x * x) + (y * y)); const pointTheta = getTheta(0, 0, x, y) + angle; return { x: radius * Math.cos(pointTheta), y: radius * Math.sin(pointTheta), }; }; const getRotated = (xRaw, yRaw, ratio = true) => { // Multiplying by video dimensions to have the axes' scales match the video's // Using midPoint's raw values would only work if points moved elliptically around the centre of rotation const rotated = getGenericRotated(xRaw * video.clientWidth, yRaw * video.clientHeight, videoAngle - PI_HALVES[0]); return ratio ? {x: rotated.x / video.clientWidth, y: rotated.y / video.clientHeight} : rotated; }; const applyZoom = (() => { const getFramer = (() => { let priorTheta, fitContentZoom; return () => { if (videoAngle !== priorTheta) { priorTheta = videoAngle; fitContentZoom = getFitContentZoom(); } return fitContentZoom; }; })(); const constrain = () => { const {zoomOutLimit, zoomInLimit} = $config.get(); const framer = getFramer(); if (zoomOutLimit.type > 0) { zoom = Math.max(zoomOutLimit.type === 1 ? zoomOutLimit.value : framer, zoom); } if (zoomInLimit.type > 0) { zoom = Math.min(zoomInLimit.type === 1 ? zoomInLimit.value : framer, zoom); } }; return (doApply = true) => { if (isOn) { constrain(); } if (doApply) { video.style.setProperty('scale', `${zoom}`); } return zoom; }; })(); const applyMidPoint = () => { const {x, y} = getRotated(midPoint.x, midPoint.y); video.style.setProperty('translate', `${-x * zoom * 100}% ${y * zoom * 100}%`); }; const ensureFramed = (() => { const applyFrameValues = (lowCorner, highCorner, sub, main) => { midPoint[sub] = Math.max(-lowCorner[sub], Math.min(highCorner[sub], midPoint[sub])); const progress = (midPoint[sub] + lowCorner[sub]) / (highCorner[sub] + lowCorner[sub]); if (midPoint[main] < 0) { const bound = Number.isNaN(progress) ? -lowCorner[main] : ((lowCorner[main] - highCorner[main]) * progress - lowCorner[main]); midPoint[main] = Math.max(midPoint[main], bound); } else { const bound = Number.isNaN(progress) ? lowCorner[main] : ((highCorner[main] - lowCorner[main]) * progress + lowCorner[main]); midPoint[main] = Math.min(midPoint[main], bound); } }; const applyFrame = (firstCorner, secondCorner, firstCornerAngle, secondCornerAngle) => { // The anti-clockwise angle from the first (top left) corner const midPointAngle = (getTheta(0, 0, midPoint.x, midPoint.y) + PI_HALVES[1] + firstCornerAngle) % PI_HALVES[3]; if ((midPointAngle % PI_HALVES[1]) < secondCornerAngle) { // Frame is x-bound const [lowCorner, highCorner] = midPoint.x >= 0 ? [firstCorner, secondCorner] : [secondCorner, firstCorner]; applyFrameValues(lowCorner, highCorner, 'y', 'x'); } else { // Frame is y-bound const [lowCorner, highCorner] = midPoint.y >= 0 ? [firstCorner, secondCorner] : [secondCorner, firstCorner]; applyFrameValues(lowCorner, highCorner, 'x', 'y'); } }; const getBoundApplyFrame = (() => { const getCorner = (first, second) => { if (zoom < first.z) { return {x: 0, y: 0}; } if (zoom < second.z) { const progress = (1 / zoom - 1 / first.z) / (1 / second.z - 1 / first.z); return { x: progress * (second.x - first.x) + first.x, y: progress * (second.y - first.y) + first.y, }; } return { x: Math.max(0, 0.5 - ((0.5 - second.x) / (zoom / second.z))), y: Math.max(0, 0.5 - ((0.5 - second.y) / (zoom / second.z))), }; }; return (first0, second0, first1, second1) => { const fFirstCorner = getCorner(first0, second0); const fSecondCorner = getCorner(first1, second1); const fFirstCornerAngle = getTheta(0, 0, fFirstCorner.x, fFirstCorner.y); const fSecondCornerAngle = fFirstCornerAngle + getTheta(0, 0, fSecondCorner.x, fSecondCorner.y); return applyFrame.bind(null, fFirstCorner, fSecondCorner, fFirstCornerAngle, fSecondCornerAngle); }; })(); // https://math.stackexchange.com/questions/2223691/intersect-2-lines-at-the-same-ratio-through-a-point const snapZoom = (() => { const isAbove = (x, y, m, c) => (m * x + c) < y; const getPSecond = (low, high) => 1 - (low / high); const getPFirst = (low, high, target) => (target - low) / (high - low); const getProgressed = (p, [fromX, fromY], [toX, toY]) => [p * (toX - fromX) + fromX, p * (toY - fromY) + fromY]; const getFlipped = (first, second, flipX, flipY) => { const flippedFirst = []; const flippedSecond = []; const corner = []; if (flipX) { flippedFirst[0] = -first.x; flippedSecond[0] = -second.x; corner[0] = -0.5; } else { flippedFirst[0] = first.x; flippedSecond[0] = second.x; corner[0] = 0.5; } if (flipY) { flippedFirst[1] = -first.y; flippedSecond[1] = -second.y; corner[1] = -0.5; } else { flippedFirst[1] = first.y; flippedSecond[1] = second.y; corner[1] = 0.5; } return [flippedFirst, flippedSecond, corner]; }; const getIntersectPSecond = ([[from0X, from0Y], [to0X, to0Y]], [[from1X, from1Y], [to1X, to1Y]], doFlip) => { const x = Math.abs(midPoint.x); const y = Math.abs(midPoint.y); const d = to0Y; const e = from0Y; const f = to0X; const g = from0X; const h = to1Y; const i = from1Y; const j = to1X; const k = from1X; const a = d * j - d * k - j * e + e * k - h * f + h * g + i * f - i * g; const b = d * k - d * x - e * k + e * x + j * e - k * e - j * y + k * y - h * g + h * x + i * g - i * x - f * i + g * i + f * y - g * y; const c = k * e - e * x - k * y - g * i + i * x + g * y; return (doFlip ? (-b - Math.sqrt(b * b - 4 * a * c)) : (-b + Math.sqrt(b * b - 4 * a * c))) / (2 * a); }; const applyZoomPairSecond = ([z, ...pair], doFlip) => { const p = getIntersectPSecond(...pair, doFlip); if (p >= 0) { zoom = p >= 1 ? Number.MAX_SAFE_INTEGER : (z / (1 - p)); return true; } return false; }; const applyZoomPairFirst = ([z0, z1, ...pair], doFlip) => { const p = getIntersectPSecond(...pair, doFlip); if (p >= 0) { zoom = p * (z1 - z0) + z0; return true; } return false; }; return (first0, second0, first1, second1) => { const getPairings = (flipX0, flipY0, flipX1, flipY1) => { const [flippedFirst0, flippedSecond0, corner0] = getFlipped(first0, second0, flipX0, flipY0); const [flippedFirst1, flippedSecond1, corner1] = getFlipped(first1, second1, flipX1, flipY1); if (second0.z > second1.z) { const progressedHigh = getProgressed(getPSecond(second1.z, second0.z), flippedSecond1, corner1); const pairHigh = [ second0.z, [flippedSecond0, corner0], [progressedHigh, corner1], ]; if (second1.z > first0.z) { const progressedLow = getProgressed(getPFirst(first0.z, second0.z, second1.z), flippedFirst0, flippedSecond0); return [ pairHigh, [ second1.z, second0.z, [progressedLow, flippedSecond0], [flippedSecond1, progressedHigh], ], ]; } const progressedLow = getProgressed(getPSecond(second1.z, first0.z), flippedSecond1, corner1); return [ pairHigh, [ first0.z, second0.z, [flippedFirst0, flippedSecond0], [progressedLow, progressedHigh], ], ]; } const progressedHigh = getProgressed(getPSecond(second0.z, second1.z), flippedSecond0, corner0); const pairHigh = [ second1.z, [progressedHigh, corner0], [flippedSecond1, corner1], ]; if (second0.z > first1.z) { const progressedLow = getProgressed(getPFirst(first1.z, second1.z, second0.z), flippedFirst1, flippedSecond1); return [ pairHigh, [ second0.z, second1.z, [progressedLow, flippedSecond1], [flippedSecond0, progressedHigh], ], ]; } const progressedLow = getProgressed(getPSecond(second0.z, first1.z), flippedSecond0, corner0); return [ pairHigh, [ first1.z, second1.z, [flippedFirst1, flippedSecond1], [progressedLow, progressedHigh], ], ]; }; const [pair0, pair1, doFlip = false] = (() => { const doInvert = (midPoint.x >= 0) === (midPoint.y < 0); if (doInvert) { const m = (second0.y - 0.5) / (second0.x - 0.5); const c = 0.5 - m * 0.5; if (isAbove(Math.abs(midPoint.x), Math.abs(midPoint.y), m, c)) { return [...getPairings(false, false, true, false), true]; } return getPairings(false, false, false, true); } const m = (second1.y - 0.5) / (second1.x - 0.5); const c = 0.5 - m * 0.5; if (isAbove(Math.abs(midPoint.x), Math.abs(midPoint.y), m, c)) { return getPairings(true, false, false, false); } return [...getPairings(false, true, false, false), true]; })(); if (applyZoomPairSecond(pair0, doFlip) || applyZoomPairFirst(pair1, doFlip)) { return; } zoom = pair1[0]; }; })(); const getZoomBoundApplyFrameGetter = (() => () => { const videoWidth = video.clientWidth / 2; const videoHeight = video.clientHeight / 2; const viewportWidth = viewport.clientWidth / 2; const viewportHeight = viewport.clientHeight / 2; const quadrant = Math.floor(videoAngle / PI_HALVES[0]) + 3; const [xAngle, yAngle] = (() => { const angle = (videoAngle + PI_HALVES[3]) % PI_HALVES[0]; return (quadrant % 2 === 0) ? [PI_HALVES[0] - angle, angle] : [angle, PI_HALVES[0] - angle]; })(); const progress = (xAngle / PI_HALVES[0]) * 2 - 1; // equivalent: // const progress = (yAngle / PI_HALVES[0]) * -2 + 1; const cornerAZero = (() => { const angleA = progress * viewportSectorAngles.side; const angleB = PI_HALVES[0] - angleA - yAngle; return { // todo broken i guess :) x: Math.abs((viewportWidth * Math.sin(angleA)) / (videoWidth * Math.cos(angleB))), y: Math.abs((viewportWidth * Math.cos(angleB)) / (videoHeight * Math.cos(angleA))), }; })(); const cornerBZero = (() => { const angleA = progress * viewportSectorAngles.base; const angleB = PI_HALVES[0] - angleA - yAngle; return { x: Math.abs((viewportHeight * Math.cos(angleA)) / (videoWidth * Math.cos(angleB))), y: Math.abs((viewportHeight * Math.sin(angleB)) / (videoHeight * Math.cos(angleA))), }; })(); const [cornerAX, cornerAY, cornerBX, cornerBY] = (() => { const getCornerA = (() => { const angleA = progress * viewportSectorAngles.side; const angleB = PI_HALVES[0] - angleA - yAngle; return (zoom) => { const h = (viewportWidth / zoom) / Math.cos(angleA); const xBound = Math.max(0, videoWidth - (Math.sin(angleB) * h)); const yBound = Math.max(0, videoHeight - (Math.cos(angleB) * h)); return { x: xBound / video.clientWidth, y: yBound / video.clientHeight, }; }; })(); const getCornerB = (() => { const angleA = progress * viewportSectorAngles.base; const angleB = PI_HALVES[0] - angleA - yAngle; return (zoom) => { const h = (viewportHeight / zoom) / Math.cos(angleA); const xBound = Math.max(0, videoWidth - (Math.cos(angleB) * h)); const yBound = Math.max(0, videoHeight - (Math.sin(angleB) * h)); return { x: xBound / video.clientWidth, y: yBound / video.clientHeight, }; }; })(); return [ getCornerA(cornerAZero.x), getCornerA(cornerAZero.y), getCornerB(cornerBZero.x), getCornerB(cornerBZero.y), ]; })(); const cornerAVars = cornerAZero.x < cornerAZero.y ? [{z: cornerAZero.x, ...cornerAX}, {z: cornerAZero.y, ...cornerAY}] : [{z: cornerAZero.y, ...cornerAY}, {z: cornerAZero.x, ...cornerAX}]; const cornerBVars = cornerBZero.x < cornerBZero.y ? [{z: cornerBZero.x, ...cornerBX}, {z: cornerBZero.y, ...cornerBY}] : [{z: cornerBZero.y, ...cornerBY}, {z: cornerBZero.x, ...cornerBX}]; if (quadrant % 2 === 0) { return [ getBoundApplyFrame.bind(null, ...cornerAVars, ...cornerBVars), snapZoom.bind(null, ...cornerAVars, ...cornerBVars), ]; } return [ getBoundApplyFrame.bind(null, ...cornerBVars, ...cornerAVars), snapZoom.bind(null, ...cornerBVars, ...cornerAVars), ]; })(); const handlers = [ () => { applyMidPoint(); }, (doZoom, ratio) => { if (doZoom) { applyZoom(); } const bound = 0.5 + (ratio - 0.5) / zoom; midPoint.x = Math.max(-bound, Math.min(bound, midPoint.x)); midPoint.y = Math.max(-bound, Math.min(bound, midPoint.y)); applyMidPoint(); }, (() => { let priorTheta, priorZoom, getZoomBoundApplyFrame, boundSnapZoom, boundApplyFrame; return (doZoom) => { if (videoAngle !== priorTheta) { [getZoomBoundApplyFrame, boundSnapZoom] = getZoomBoundApplyFrameGetter(); boundApplyFrame = getZoomBoundApplyFrame(); priorTheta = videoAngle; priorZoom = zoom; } else if (!doZoom && zoom !== priorZoom) { boundApplyFrame = getZoomBoundApplyFrame(); priorZoom = zoom; } if (doZoom) { boundSnapZoom(); applyZoom(); ensureFramed(); return; } boundApplyFrame(); applyMidPoint(); }; })(), ]; return (doZoom = false) => { const {panLimit} = $config.get(); return handlers[panLimit.type](doZoom, panLimit.value); }; })(); const applyRotation = () => { // Conversion from anticlockwise rotation from the x-axis to clockwise rotation from the y-axis video.style.setProperty('rotate', `${PI_HALVES[0] - videoAngle}rad`); }; const rotate = (change) => { videoAngle = (videoAngle + change) % PI_HALVES[3]; if (videoAngle > PI_HALVES[0]) { videoAngle -= PI_HALVES[3]; } else if (videoAngle <= -PI_HALVES[2]) { videoAngle += PI_HALVES[3]; } applyRotation(); // for fit-content zoom applyZoom(); }; const actions = { crop: new function() { const currentCrop = {}; let handle; class Button { // allowance for rounding errors static ALLOWANCE_HANDLE = 0.0001; static CLASS_HANDLE = 'ytvz-crop-handle'; static CLASS_EDGES = { left: 'ytvz-crop-left', top: 'ytvz-crop-top', right: 'ytvz-crop-right', bottom: 'ytvz-crop-bottom', }; static OPPOSITES = { left: 'right', right: 'left', top: 'bottom', bottom: 'top', }; callbacks = []; element = document.createElement('div'); constructor(...edges) { this.edges = edges; this.isHandle = true; this.element.style.position = 'absolute'; this.element.style.pointerEvents = 'all'; for (const edge of edges) { this.element.style[edge] = '0'; this.element.classList.add(Button.CLASS_EDGES[edge]); this.element.style.setProperty(`border-${Button.OPPOSITES[edge]}-width`, '1px'); } this.element.addEventListener('contextmenu', (event) => { event.stopPropagation(); event.preventDefault(); this.reset(false); }); this.element.addEventListener('pointerdown', (() => { const getDragListener = (event, target) => { const getWidth = (() => { if (this.edges.includes('left')) { const position = this.element.clientWidth - event.offsetX; return ({offsetX}) => offsetX + position; } const position = target.offsetWidth + event.offsetX; return ({offsetX}) => position - offsetX; })(); const getHeight = (() => { if (this.edges.includes('top')) { const position = this.element.clientHeight - event.offsetY; return ({offsetY}) => offsetY + position; } const position = target.offsetHeight + event.offsetY; return ({offsetY}) => position - offsetY; })(); return (event) => { this.set({ width: getWidth(event) / video.clientWidth, height: getHeight(event) / video.clientHeight, }); }; }; const clickListener = ({offsetX, offsetY, target}) => { this.set({ width: (this.edges.includes('left') ? offsetX : target.clientWidth - offsetX) / video.clientWidth, height: (this.edges.includes('top') ? offsetY : target.clientHeight - offsetY) / video.clientHeight, }, false); }; return async (event) => { if (event.buttons === 1) { const target = this.element.parentElement; await handleMouseDown(event, clickListener, getDragListener(event, target), target); this.updateCounterpart(); } }; })()); } notify(property) { for (const callback of this.callbacks) { callback(this.element.style[property], property); } } set isHandle(value) { this._isHandle = value; this.element.classList[value ? 'add' : 'remove'](Button.CLASS_HANDLE); } get isHandle() { return this._isHandle; } reset() { this.isHandle = true; for (const edge of this.edges) { currentCrop[edge] = 0; } } } class EdgeButton extends Button { constructor(edge) { super(edge); this.edge = edge; } updateCounterpart() { if (this.counterpart.isHandle) { this.counterpart.setHandle(); } } setCrop(value = 0) { currentCrop[this.edge] = value; } } class SideButton extends EdgeButton { flow() { const {top, bottom} = currentCrop; let size = 100; if (top <= Button.ALLOWANCE_HANDLE) { size -= handle; this.element.style.top = `${handle}%`; } else { size -= top * 100; this.element.style.top = `${top * 100}%`; } if (bottom <= Button.ALLOWANCE_HANDLE) { size -= handle; } else { size -= bottom * 100; } this.element.style.height = `${Math.max(0, size)}%`; } setBounds(counterpart, components) { this.counterpart = components[counterpart]; components.top.callbacks.push(() => { this.flow(); }); components.bottom.callbacks.push(() => { this.flow(); }); } notify() { super.notify('width'); } setHandle(doNotify = true) { this.element.style.width = `${Math.min((1 - currentCrop[this.counterpart.edge]) * 100, handle)}%`; if (doNotify) { this.notify(); } } set({width}, doUpdateCounterpart = true) { const wasHandle = this.isHandle; this.isHandle = width <= Button.ALLOWANCE_HANDLE; if (wasHandle !== this.isHandle) { this.flow(); } if (doUpdateCounterpart) { this.updateCounterpart(); } if (this.isHandle) { this.setCrop(); this.setHandle(); return; } const size = Math.min(1 - currentCrop[this.counterpart.edge], width); this.setCrop(size); this.element.style.width = `${size * 100}%`; this.notify(); } reset(isGeneral = true) { super.reset(); if (isGeneral) { this.element.style.top = `${handle}%`; this.element.style.height = `${100 - handle * 2}%`; this.element.style.width = `${handle}%`; return; } this.flow(); this.setHandle(); this.updateCounterpart(); } } class BaseButton extends EdgeButton { flow() { const {left, right} = currentCrop; let size = 100; if (left <= Button.ALLOWANCE_HANDLE) { size -= handle; this.element.style.left = `${handle}%`; } else { size -= left * 100; this.element.style.left = `${left * 100}%`; } if (right <= Button.ALLOWANCE_HANDLE) { size -= handle; } else { size -= right * 100; } this.element.style.width = `${Math.max(0, size)}%`; } setBounds(counterpart, components) { this.counterpart = components[counterpart]; components.left.callbacks.push(() => { this.flow(); }); components.right.callbacks.push(() => { this.flow(); }); } notify() { super.notify('height'); } setHandle(doNotify = true) { this.element.style.height = `${Math.min((1 - currentCrop[this.counterpart.edge]) * 100, handle)}%`; if (doNotify) { this.notify(); } } set({height}, doUpdateCounterpart = false) { const wasHandle = this.isHandle; this.isHandle = height <= Button.ALLOWANCE_HANDLE; if (wasHandle !== this.isHandle) { this.flow(); } if (doUpdateCounterpart) { this.updateCounterpart(); } if (this.isHandle) { this.setCrop(); this.setHandle(); return; } const size = Math.min(1 - currentCrop[this.counterpart.edge], height); this.setCrop(size); this.element.style.height = `${size * 100}%`; this.notify(); } reset(isGeneral = true) { super.reset(); if (isGeneral) { this.element.style.left = `${handle}%`; this.element.style.width = `${100 - handle * 2}%`; this.element.style.height = `${handle}%`; return; } this.flow(); this.setHandle(); this.updateCounterpart(); } } class CornerButton extends Button { static CLASS_NAME = 'ytvz-crop-corner'; constructor(sectors, ...edges) { super(...edges); this.element.classList.add(CornerButton.CLASS_NAME); this.sectors = sectors; for (const sector of sectors) { sector.callbacks.push(this.flow.bind(this)); } } flow() { let isHandle = true; if (this.sectors[0].isHandle) { this.element.style.width = `${Math.min((1 - currentCrop[this.sectors[0].counterpart.edge]) * 100, handle)}%`; } else { this.element.style.width = `${currentCrop[this.edges[0]] * 100}%`; isHandle = false; } if (this.sectors[1].isHandle) { this.element.style.height = `${Math.min((1 - currentCrop[this.sectors[1].counterpart.edge]) * 100, handle)}%`; } else { this.element.style.height = `${currentCrop[this.edges[1]] * 100}%`; isHandle = false; } this.isHandle = isHandle; } updateCounterpart() { for (const sector of this.sectors) { sector.updateCounterpart(); } } set(size) { for (const sector of this.sectors) { sector.set(size); } } reset(isGeneral = true) { this.isHandle = true; this.element.style.width = `${handle}%`; this.element.style.height = `${handle}%`; if (isGeneral) { return; } for (const sector of this.sectors) { sector.reset(false); } } } this.CODE = 'crop'; this.CLASS_ABLE = 'ytvz-action-able-crop'; const container = document.createElement('div'); // todo ditch the containers object container.style.width = container.style.height = 'inherit'; containers.foreground.append(container); this.onRightClick = (event) => { if (event.target.parentElement.id === container.id) { return; } event.stopPropagation(); event.preventDefault(); if (stopDrag) { return; } for (const component of Object.values(this.components)) { component.reset(true); } }; this.onScroll = (event) => { const {speeds} = $config.get(); event.stopImmediatePropagation(); event.preventDefault(); if (event.deltaY === 0) { return; } const increment = event.deltaY * speeds.crop / zoom; const {top, left, right, bottom} = currentCrop; this.components.top.set({height: top + Math.min((1 - top - bottom) / 2, increment)}); this.components.left.set({width: left + Math.min((1 - left - right) / 2, increment)}); this.components.bottom.set({height: bottom + increment}); this.components.right.set({width: right + increment}); }; this.onMouseDown = (() => { const getDragListener = ({offsetX, offsetY}) => { const {multipliers} = $config.get(); const {top, left, right, bottom} = currentCrop; const clampX = (value) => Math.max(-left, Math.min(right, value)); const clampY = (value) => Math.max(-top, Math.min(bottom, value)); return (event) => { const incrementX = clampX((offsetX - event.offsetX) * multipliers.crop / video.clientWidth); const incrementY = clampY((offsetY - event.offsetY) * multipliers.crop / video.clientHeight); this.components.top.set({height: top + incrementY}); this.components.left.set({width: left + incrementX}); this.components.bottom.set({height: bottom - incrementY}); this.components.right.set({width: right - incrementX}); }; }; const clickListener = () => { const {top, left, right, bottom} = currentCrop; zoom = getFitContentZoom(1 - left - right, 1 - top - bottom); applyZoom(); midPoint.x = (left - right) / 2; midPoint.y = (bottom - top) / 2; ensureFramed(); }; return (event) => { if (event.buttons === 1) { handleMouseDown(event, clickListener, getDragListener(event), container); } }; })(); this.components = { top: new BaseButton('top'), right: new SideButton('right'), bottom: new BaseButton('bottom'), left: new SideButton('left'), }; this.components.top.setBounds('bottom', this.components); this.components.right.setBounds('left', this.components); this.components.bottom.setBounds('top', this.components); this.components.left.setBounds('right', this.components); this.components.topLeft = new CornerButton([this.components.left, this.components.top], 'left', 'top'); this.components.topRight = new CornerButton([this.components.right, this.components.top], 'right', 'top'); this.components.bottomLeft = new CornerButton([this.components.left, this.components.bottom], 'left', 'bottom'); this.components.bottomRight = new CornerButton([this.components.right, this.components.bottom], 'right', 'bottom'); container.append(...Object.values(this.components).map(({element}) => element)); const cropRule = new css.Toggleable(); this.onInactive = () => { const {top, left, right, bottom} = currentCrop; Object.assign(crop, currentCrop); background.handleViewChange(); cropRule.add( `${SELECTOR_VIDEO}:not(.${this.CLASS_ABLE} *):not(.${button.CLASS_PEEK} *)`, ['clip-path', `inset(${top * 100}% ${right * 100}% ${bottom * 100}% ${left * 100}%)`], ); addListeners(this, false); background.reset(); }; this.onActive = () => { const config = $config.get().crop; handle = config.handle / Math.max(zoom, 1); for (const component of [this.components.top, this.components.bottom, this.components.left, this.components.right]) { if (component.isHandle) { component.setHandle(); } } Object.assign(currentCrop, crop); crop.top = crop.bottom = crop.left = crop.right = 0; addListeners(this); if (!Enabler.isHidingBg) { background.handleViewChange(); background.reset(); } }; this.stop = () => { crop.top = crop.bottom = crop.left = crop.right = 0; for (const component of Object.values(this.components)) { component.reset(true); } cropRule.remove(); }; const draggingSelector = css.getSelector(Enabler.CLASS_DRAGGING); this.updateConfig = (() => { const rule = new css.Toggleable(); return () => { Object.assign(currentCrop, crop); // set handle size for (const button of [this.components.left, this.components.top, this.components.right, this.components.bottom]) { if (button.isHandle) { button.setHandle(); } } rule.remove(); const {colour} = $config.get().crop; const {id} = container; rule.add(`#${id}>:hover.${Button.CLASS_HANDLE},#${id}>:not(.${Button.CLASS_HANDLE})`, ['background-color', colour.fill]); rule.add(`#${id}>*`, ['border-color', colour.border]); rule.add(`#${id}:not(${draggingSelector} *)>:not(:hover)`, ['filter', `drop-shadow(${colour.shadow} 0 0 1px)`]); }; })(); $config.ready.then(() => { this.updateConfig(); }); container.id = 'ytvz-crop-container'; (() => { const {id} = container; css.add(`${css.getSelector(Enabler.CLASS_DRAGGING)} #${id}`, ['cursor', 'grabbing']); css.add(`${css.getSelector(Enabler.CLASS_ABLE)} #${id}`, ['cursor', 'grab']); css.add(`#${id}>:not(${draggingSelector} .${Button.CLASS_HANDLE})`, ['border-style', 'solid']); css.add(`${draggingSelector} #${id}>.${Button.CLASS_HANDLE}`, ['filter', 'none']); // in fullscreen, 16:9 videos get an offsetLeft of 1px on my 16:9 monitor // therefore I'm extending left-side buttons by 1px so that they still reach the edge of the screen css.add(`#${id}>.${Button.CLASS_EDGES.left}`, ['margin-left', '-1px'], ['padding-left', '1px']); for (const [side, sideClass] of Object.entries(Button.CLASS_EDGES)) { css.add( `${draggingSelector} #${id}>.${sideClass}.${Button.CLASS_HANDLE}~.${sideClass}.${CornerButton.CLASS_NAME}`, [`border-${CornerButton.OPPOSITES[side]}-style`, 'none'], ['filter', 'none'], ); } css.add(`#${id}:not(.${this.CLASS_ABLE} *)`, ['display', 'none']); })(); }(), pan: new function() { this.CODE = 'pan'; this.CLASS_ABLE = 'ytvz-action-able-pan'; this.onActive = () => { this.updateCrosshair(); addListeners(this); }; this.onInactive = () => { addListeners(this, false); }; this.updateCrosshair = (() => { const getRoundedString = (number, decimal = 2) => { const raised = `${Math.round(number * Math.pow(10, decimal))}`.padStart(decimal + 1, '0'); return `${raised.substr(0, raised.length - decimal)}.${raised.substr(raised.length - decimal)}`; }; const getSigned = (ratio) => { const percent = Math.round(ratio * 100); if (percent <= 0) { return `${percent}`; } return `+${percent}`; }; return () => { crosshair.text.innerText = `${getRoundedString(zoom)}×\n${getSigned(midPoint.x)}%\n${getSigned(midPoint.y)}%`; }; })(); this.onScroll = (event) => { const {speeds} = $config.get(); event.stopImmediatePropagation(); event.preventDefault(); if (event.deltaY === 0) { return; } const increment = event.deltaY * speeds.zoom; if (increment > 0) { zoom *= 1 + increment; } else { zoom /= 1 - increment; } applyZoom(); ensureFramed(); this.updateCrosshair(); }; this.onRightClick = (event) => { event.stopImmediatePropagation(); event.preventDefault(); if (stopDrag) { return; } resetMidPoint(); resetZoom(); this.updateCrosshair(); }; this.onMouseDown = (() => { const getDragListener = () => { const {multipliers} = $config.get(); let priorEvent; const change = {x: 0, y: 0}; return (event) => { if (!priorEvent) { priorEvent = event; return; } change.x = (event.offsetX - (priorEvent.offsetX + change.x)) * multipliers.pan; change.y = (event.offsetY - (priorEvent.offsetY - change.y)) * -multipliers.pan; midPoint.x += change.x / video.clientWidth; midPoint.y += change.y / video.clientHeight; ensureFramed(); this.updateCrosshair(); priorEvent = event; }; }; const clickListener = (event) => { const position = { x: (event.offsetX / video.clientWidth) - 0.5, // Y increases moving down the page // I flip that to make trigonometry easier y: (-event.offsetY / video.clientHeight) + 0.5, }; midPoint.x = position.x; midPoint.y = position.y; ensureFramed(true); this.updateCrosshair(); }; return (event) => { if (event.buttons === 1) { handleMouseDown(event, clickListener, getDragListener()); } }; })(); }(), rotate: new function() { this.CODE = 'rotate'; this.CLASS_ABLE = 'ytvz-action-able-rotate'; this.onActive = () => { this.updateCrosshair(); addListeners(this); }; this.onInactive = () => { addListeners(this, false); }; this.updateCrosshair = () => { const angle = PI_HALVES[0] - videoAngle; crosshair.text.innerText = `${Math.floor((PI_HALVES[0] - videoAngle) / Math.PI * 180)}°\n≈${Math.round(angle / PI_HALVES[0]) % 4 * 90}°`; }; this.onScroll = (event) => { const {speeds} = $config.get(); event.stopImmediatePropagation(); event.preventDefault(); rotate(speeds.rotate * event.deltaY); ensureFramed(); this.updateCrosshair(); }; this.onRightClick = (event) => { event.stopImmediatePropagation(); event.preventDefault(); if (stopDrag) { return; } resetRotation(); this.updateCrosshair(); }; this.onMouseDown = (() => { const getDragListener = () => { const {multipliers} = $config.get(); const middleX = containers.tracker.clientWidth / 2; const middleY = containers.tracker.clientHeight / 2; const priorMidPoint = {...midPoint}; let priorMouseTheta; return (event) => { const mouseTheta = getTheta(middleX, middleY, event.offsetX, event.offsetY); if (priorMouseTheta === undefined) { priorMouseTheta = mouseTheta; return; } rotate((mouseTheta - priorMouseTheta) * multipliers.rotate); // only useful for the 'Frame' panLimit // looks weird but it's probably useful midPoint.x = priorMidPoint.x; midPoint.y = priorMidPoint.y; ensureFramed(); this.updateCrosshair(); priorMouseTheta = mouseTheta; }; }; const clickListener = () => { const theta = Math.abs(videoAngle) % PI_HALVES[0]; const progress = theta / PI_HALVES[0]; rotate(Math.sign(videoAngle) * (progress < 0.5 ? -theta : (PI_HALVES[0] - theta))); ensureFramed(); this.updateCrosshair(); }; return (event) => { if (event.buttons === 1) { handleMouseDown(event, clickListener, getDragListener(), containers.tracker); } }; })(); }(), }; 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 = 'ytvz-crosshair'; this.container.id = id; css.add(`#${id}:not(${css.getSelector(actions.pan.CLASS_ABLE)} *):not(${css.getSelector(actions.rotate.CLASS_ABLE)} *)`, ['display', 'none']); this.lines.horizontal.style.position = this.lines.vertical.style.position = this.text.style.position = this.container.style.position = 'absolute'; this.lines.horizontal.style.top = '50%'; this.lines.horizontal.style.width = '100%'; this.lines.vertical.style.left = '50%'; this.lines.vertical.style.height = '100%'; this.text.style.userSelect = 'none'; this.container.style.top = '0'; this.container.style.width = '100%'; this.container.style.height = '100%'; this.container.style.pointerEvents = 'none'; this.container.append(this.lines.horizontal, this.lines.vertical, this.text); this.clip = () => { const {outer, inner, gap} = $config.get().crosshair; const thickness = Math.max(inner, outer); const halfWidth = viewport.clientWidth / 2; const halfHeight = viewport.clientHeight / 2; const halfGap = gap / 2; const startInner = (thickness - inner) / 2; const startOuter = (thickness - outer) / 2; const endInner = thickness - startInner; const endOuter = thickness - startOuter; this.lines.horizontal.style.clipPath = 'path(\'' + `M0 ${startOuter}L${halfWidth - halfGap} ${startOuter}L${halfWidth - halfGap} ${startInner}L${halfWidth + halfGap} ${startInner}L${halfWidth + halfGap} ${startOuter}L${viewport.clientWidth} ${startOuter}` + `L${viewport.clientWidth} ${endOuter}L${halfWidth + halfGap} ${endOuter}L${halfWidth + halfGap} ${endInner}L${halfWidth - halfGap} ${endInner}L${halfWidth - halfGap} ${endOuter}L0 ${endOuter}` + 'Z\')'; this.lines.vertical.style.clipPath = 'path(\'' + `M${startOuter} 0L${startOuter} ${halfHeight - halfGap}L${startInner} ${halfHeight - halfGap}L${startInner} ${halfHeight + halfGap}L${startOuter} ${halfHeight + halfGap}L${startOuter} ${viewport.clientHeight}` + `L${endOuter} ${viewport.clientHeight}L${endOuter} ${halfHeight + halfGap}L${endInner} ${halfHeight + halfGap}L${endInner} ${halfHeight - halfGap}L${endOuter} ${halfHeight - halfGap}L${endOuter} 0` + 'Z\')'; }; this.updateConfig = (doClip = true) => { const {colour, outer, inner, text} = $config.get().crosshair; const thickness = Math.max(inner, outer); this.container.style.filter = `drop-shadow(${colour.shadow} 0 0 1px)`; this.lines.horizontal.style.translate = `0 -${thickness / 2}px`; this.lines.vertical.style.translate = `-${thickness / 2}px 0`; this.lines.horizontal.style.height = this.lines.vertical.style.width = `${thickness}px`; this.text.style.color = this.lines.horizontal.style.backgroundColor = this.lines.vertical.style.backgroundColor = colour.fill; this.text.style.font = text.font; this.text.style.left = `${text.position.x}%`; this.text.style.top = `${text.position.y}%`; this.text.style.transform = `translate(${text.translate.x}%,${text.translate.y}%) translate(${text.offset.x}px,${text.offset.y}px)`; this.text.style.textAlign = text.align; this.text.style.lineHeight = text.height; if (doClip) { this.clip(); } }; $config.ready.then(() => { this.updateConfig(false); }); }(); const observer = new function() { const onVideoEnd = () => { if (isOn) { stop(); } video.addEventListener('play', () => { if (isOn) { start(); } }, {once: true}); }; const onResolutionChange = () => { background.handleSizeChange?.(); }; const styleObserver = new MutationObserver((() => { const properties = ['top', 'left', 'width', 'height', 'scale', 'rotate', 'translate']; let priorStyle; return () => { // mousemove events on video with ctrlKey=true trigger this but have no effect if (video.style.cssText === priorStyle) { return; } priorStyle = video.style.cssText; for (const property of properties) { containers.background.style[property] = video.style[property]; containers.foreground.style[property] = video.style[property]; } background.handleViewChange(); }; })()); const videoObserver = new ResizeObserver(() => { setViewportAngles(); background.handleSizeChange?.(); }); const viewportObserver = new ResizeObserver(() => { setViewportAngles(); crosshair.clip(); }); this.start = () => { video.addEventListener('ended', onVideoEnd); video.addEventListener('resize', onResolutionChange); styleObserver.observe(video, {attributes: true, attributeFilter: ['style']}); videoObserver.observe(video); viewportObserver.observe(viewport); background.handleViewChange(); }; this.stop = () => { // delay stopping to reset observed elements window.setTimeout(() => { video.removeEventListener('ended', onVideoEnd); video.removeEventListener('resize', onResolutionChange); styleObserver.disconnect(); videoObserver.disconnect(); viewportObserver.disconnect(); }, 0); }; }(); const kill = () => { stopDrag?.(); css.tag(Enabler.CLASS_ABLE, false); for (const {CLASS_ABLE} of Object.values(actions)) { css.tag(CLASS_ABLE, false); } Enabler.setActive(false); }; const stop = () => { kill(); observer.stop?.(); background.stop(); actions.crop.stop(); containers.background.remove(); containers.foreground.remove(); containers.tracker.remove(); crosshair.container.remove(); resetMidPoint(); resetZoom(); resetRotation(); }; const start = () => { observer.start(); background.start(); viewport.append(containers.background, containers.foreground, containers.tracker, crosshair.container); // User may have a custom minimum zoom greater than 1 applyZoom(); Enabler.handleChange(); }; const updateConfigs = () => { Enabler.updateConfig(); actions.crop.updateConfig(); crosshair.updateConfig(); }; const button = new function() { this.element = document.createElement('button'); this.element.classList.add('ytp-button', 'ytp-settings-button'); this.element.title = 'View Controls'; (() => { // SVG const box = 36; const center = box / 2; // Circle const radius = 6; const gap = 2; const ns = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(ns, 'svg'); svg.setAttribute('viewBox', `0 0 ${box} ${box}`); svg.style.rotate = '20deg'; svg.style.stroke = 'white'; svg.style.fill = 'white'; svg.style.height = '100%'; svg.style.width = '100%'; svg.style.filter = 'drop-shadow(0px 0px 1px #444)'; const clip = document.createElementNS(ns, 'clipPath'); clip.id = 'ytvzClip'; const clipPath = document.createElementNS(ns, 'path'); clipPath.style.transformOrigin = 'center'; clipPath.style.transition = '0.5s d'; clip.append(clipPath); const inner = document.createElementNS(ns, 'circle'); inner.setAttribute('r', `${(radius - gap) * 2}`); inner.setAttribute('cx', `${center}`); inner.setAttribute('cy', `${center}`); inner.setAttribute('clip-path', `url(#${clip.id})`); const outer = document.createElementNS(ns, 'circle'); outer.setAttribute('r', `${(radius - gap) * 2 + 2}`); outer.setAttribute('cx', `${center}`); outer.setAttribute('cy', `${center}`); outer.style.fill = 'none'; const spikeContainer = document.createElementNS(ns, 'g'); spikeContainer.style.strokeWidth = '0'; spikeContainer.setAttribute('transform', `translate(${box / 2} ${box / 2}) rotate(-6.5)`); const spikes = []; for (let angle = 0; angle < 360; angle += 36) { const spike = document.createElementNS(ns, 'path'); spike.style.rotate = `${angle}deg`; spike.style.transition = '0.5s d'; spikes.push(spike); } const dSpike = (() => { const baseY = (radius - gap) * -2 - 2; const spikeY = baseY - 2.2; const spikeX = 1; return { on: `path("M${-spikeX} ${baseY}L${0} ${spikeY}L${spikeX} ${baseY}Z")`, off: `path("M${-spikeX} ${baseY}L${0} ${baseY}L${spikeX} ${baseY}Z")`, }; })(); spikeContainer.append(...spikes); this.setIndicator = () => { const {on, indicate} = $config.get().button; if (!on || !indicate) { return; } const d = dSpike[(!video.ended && isOn && !background.isHidden && !Enabler.isHidingBg) ? 'on' : 'off']; for (const spike of spikes) { spike.style.d = d; } }; this.updateIndicator = () => { const {on, indicate} = $config.get().button; if (!on) { this.element.style.display = 'none'; } else { this.element.style.removeProperty('display'); } if (!indicate) { spikeContainer.style.display = 'none'; } else { spikeContainer.style.removeProperty('display'); } }; $config.ready.then(() => { this.updateIndicator(); }); svg.append(clip, inner, outer, spikeContainer); this.element.append(svg); this.update = (() => { const getPath = (stroke, offset) => 'path("' + `M0 0L0 ${box}L${box} ${box}L${box} 0Z` + `M0 ${center - stroke / 2}l${center - stroke / 2 + offset / 2} 0l0 ${-center}l${stroke} 0l0 ${center}l${center} 0l0 ${stroke}l${-center - offset} 0l0 ${center}l${-stroke} 0l0 ${-center}l${-center} 0Z` + '")'; const pathOn = getPath(3, 2); const pathOff = getPath(0, 0); return () => clipPath.style.d = isOn ? pathOn : pathOff; })(); })(); this.edit = async () => { await $config.edit(); background.reset(); this.updateIndicator(); this.setIndicator(); updateConfigs(); viewport.focus(); ensureFramed(); applyZoom(); }; this.handleKeyChange = () => { if (!viewport) { return; } const {keys} = $config.get(); const doClick = Enabler.keys.isSupersetOf(keys.on); const doConfig = Enabler.keys.isSupersetOf(keys.config) && (!doClick || keys.on.size < keys.config.size); if (doConfig) { this.edit(); kill(); } else if (doClick) { this.element.click(); } }; const KEY = 'YTVZ_ON'; this.element.addEventListener('click', (event) => { event.stopPropagation(); event.preventDefault(); isOn = !isOn; GM.setValue(KEY, isOn); this.update(); if (!isOn) { stop(); } else if (!video.ended) { start(); } }); this.element.addEventListener('contextmenu', (event) => { event.stopPropagation(); event.preventDefault(); this.edit(); }); this.CLASS_PEEK = 'ytvz-peek'; this.element.addEventListener('pointerover', () => { if (!$config.get().button.peek) { return; } video.style.removeProperty('translate'); video.style.removeProperty('rotate'); video.style.removeProperty('scale'); css.tag(this.CLASS_PEEK); }); this.element.addEventListener('pointerleave', () => { if (!$config.get().button.peek) { return; } applyMidPoint(); applyRotation(); applyZoom(); css.tag(this.CLASS_PEEK, false); }); this.init = async () => { const sibling = video.parentElement.parentElement.querySelector('.ytp-subtitles-button'); isOn = false; this.update(); sibling.parentElement.insertBefore(this.element, sibling); if (await GM.getValue(KEY, true)) { this.element.click(); } }; }(); document.body.addEventListener('yt-navigate-finish', async () => { if (viewport) { stop(); } viewport = document.querySelector(SELECTOR_VIEWPORT); if (!viewport) { return; } try { await $config.ready; } catch (error) { if (!$config.reset || !window.confirm(`${error.message}\n\nWould you like to erase your data?`)) { console.error(error); return; } await $config.reset(); updateConfigs(); } video = viewport.querySelector(SELECTOR_VIDEO); altTarget = document.querySelector(SELECTOR_ROOT); containers.foreground.style.zIndex = crosshair.container.style.zIndex = video.parentElement.computedStyleMap().get('z-index').value; crosshair.clip(); setViewportAngles(); button.init(); }); // needs to be done after things are initialised (() => { const handleKeyChange = (key, isDown) => { if (Enabler.keys.has(key) === isDown) { return; } Enabler.keys[isDown ? 'add' : 'delete'](key); button.handleKeyChange(); if (isOn) { Enabler.handleChange(); } }; document.addEventListener('keydown', ({key}) => handleKeyChange(key.toLowerCase(), true)); document.addEventListener('keyup', ({key}) => handleKeyChange(key.toLowerCase(), false)); })(); window.addEventListener('blur', () => { Enabler.keys.clear(); Enabler.handleChange(); });
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址