// ==UserScript== // @name B站视频旋转与缩放 // @namespace http://tampermonkey.net/ // @version 1.4.0 // @description 为B站视频添加旋转和缩放滑条控制,支持鼠标中键滚动调节,支持鼠标左键长按拖动调整位置(不触发视频暂停/播放) // @author John Smish // @match *://www.bilibili.com/* // @match *://bilibili.com/* // @match *://*.bilibili.com/* // @grant none // @license MIT // ==/UserScript== (function() { 'use strict'; const style = document.createElement('style'); style.textContent = ` .bcmnp-rotate-box { box-sizing: border-box; position: absolute; width: 260px; height: fit-content; flex-direction: column; font-size: 12px; padding: 12px 16px 16px 16px; text-align: left; color: #fff; display: none; user-select: none; border-radius: 8px; background-color: rgba(0, 0, 0, 0.8); backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 9999; } .bpx-player-ctrl-rotate:hover { animation: none !important; } .bcmnp-rows { display: flex; flex-direction: column; gap: 20px; } .bcmnp-slider-container { display: flex; flex-direction: column; gap: 8px; } .bcmnp-label-row { display: flex; justify-content: space-between; align-items: center; } .bcmnp-label { font-size: 13px; font-weight: 500; color: rgba(255, 255, 255, 0.9); } .bcmnp-value { font-size: 13px; color: #00AEEC; font-weight: 500; } .bcmnp-slider { width: 100%; padding: 4px 0; } .bcmnp-slider input[type=range] { -webkit-appearance: none; width: 100%; height: 4px; background: rgba(255, 255, 255, 0.2); border-radius: 2px; outline: none; } .bcmnp-slider input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; background: white; border-radius: 50%; cursor: pointer; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); border: 2px solid #00AEEC; transition: transform 0.1s ease; } .bcmnp-slider input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.15); } .bcmnp-scale-presets, .bcmnp-rotate-presets { display: flex; flex-direction: row; gap: 8px; margin-top: 4px; justify-content: space-between; } .bcmnp-preset-btn { background-color: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; width: 48px; height: 28px; line-height: 28px; text-align: center; transition: all 0.2s ease; color: rgba(255, 255, 255, 0.8); font-size: 12px; cursor: pointer; } .bcmnp-preset-btn:hover { background-color: rgba(255, 255, 255, 0.25); border-color: rgba(255, 255, 255, 0.2); } .bcmnp-preset-btn.active { background-color: #00AEEC; border-color: #00AEEC; color: white; box-shadow: 0 2px 8px rgba(0, 174, 236, 0.3); } body.bcmnp-dragging-active { cursor: grabbing !important; } .bcmnp-dragging-video { cursor: grabbing !important; } .bcmnp-reset-group { display: flex; gap: 12px; margin-top: 8px; } .bcmnp-reset-group .bcmnp-reset-btn { background-color: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; padding: 6px 12px; color: rgba(255, 255, 255, 0.9); font-size: 12px; cursor: pointer; transition: all 0.2s ease; flex: 1; text-align: center; } .bcmnp-reset-group .bcmnp-reset-btn:hover { background-color: rgba(255, 255, 255, 0.25); border-color: rgba(255, 255, 255, 0.2); color: #00AEEC; } @keyframes rotateToggle { 0% { transform: scale(1) translateX(0); } 25% { transform: scale(1) translateX(-3px); } 75% { transform: scale(1) translateX(-3px); } 100% { transform: scale(1) translateX(0); } } .bpx-player-ctrl-rotate.animating .bpx-player-ctrl-btn-icon { animation: rotateToggle 0.8s ease; } .bcmnp-click-interceptor { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 99999; background: transparent; pointer-events: auto; display: none; } .bcmnp-click-interceptor.active { display: block; } `; document.head.appendChild(style); const rotateHtml = `
视频缩放 100%
视频旋转
`; function waitUntilElementReady(selector) { return new Promise((resolve, reject) => { const maxTries = 100; let trys = 0; function _checkReady() { const el = document.querySelector(selector); if (el) { resolve(el); return; } if (trys++ > maxTries) { reject(new Error(`Element ${selector} not found`)); return; } setTimeout(_checkReady, 300); } _checkReady(); }); } function insertHtmlAfterElement(element, html) { const range = document.createRange(); const frag = range.createContextualFragment(html); element.parentElement?.insertBefore(frag, element.nextSibling); } function log(message) { console.log(`[B站视频旋转与缩放] ${message}`); } function printVersion(version, cost) { console.log( `%c 🎮 B站视频旋转与缩放 v${version} %c Cost ${cost}ms 作者:John Smish QQ:3327008209`, 'background:#4A90E2;color:white;padding:2px 6px;border-radius:3px 0 0 3px;font-weight:bold;', 'background:#50E3C2;color:#003333;padding:2px 6px;border-radius:0 3px 3px 0;font-weight:bold;', ); } function rotatePoint(x, y, angle) { const rad = angle * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); return { x: x * cos - y * sin, y: x * sin + y * cos }; } const rotateToggleKeyframes = [ { '@all': { scale: '1', translate: '0px 0px' }, '#bcmnp-toggle-icon-dot': { opacity: '1' }, '#bcmnp-toggle-icon-horizontal': { scale: '1', rotate: '0deg', transformOrigin: '50% 60%', translate: '0px 0px' }, '#bcmnp-toggle-icon-vertical': { opacity: '1' }, '#bcmnp-toggle-icon-shake': { opacity: '1', rotate: '0deg', transformOrigin: '50% 60%' } }, { '@all': { scale: '1', translate: '-3px 0px' }, '#bcmnp-toggle-icon-dot': { opacity: '0' }, '#bcmnp-toggle-icon-horizontal': { scale: '1.1', rotate: '90deg', transformOrigin: '50% 60%', translate: '200px -50px' }, '#bcmnp-toggle-icon-vertical': { opacity: '0' }, '#bcmnp-toggle-icon-shake': { opacity: '0', rotate: '0deg', transformOrigin: '50% 60%' } }, { '@all': { scale: '1', translate: '-3px 0px' }, '#bcmnp-toggle-icon-dot': { opacity: '0' }, '#bcmnp-toggle-icon-horizontal': { scale: '1.1', rotate: '90deg', transformOrigin: '50% 60%', translate: '200px -50px' }, '#bcmnp-toggle-icon-vertical': { opacity: '0' }, '#bcmnp-toggle-icon-shake': { opacity: '0', rotate: '180deg', transformOrigin: '50% 60%' } }, { '@all': { scale: '1', translate: '0px 0px' }, '#bcmnp-toggle-icon-dot': { opacity: '1' }, '#bcmnp-toggle-icon-horizontal': { scale: '1', rotate: '0deg', transformOrigin: '50% 60%', translate: '0px 0px' }, '#bcmnp-toggle-icon-vertical': { opacity: '1' }, '#bcmnp-toggle-icon-shake': { opacity: '1', rotate: '0deg', transformOrigin: '50% 60%' } } ]; function animateGroup(element, keyframes, options) { const individualKeyframes = []; for (const frame of keyframes) { for (const selector in frame) { let target = null; if (selector === '@all') { target = element; } else { target = element.querySelector(selector); } if (target) { let record = individualKeyframes.find(r => r.element === target); if (!record) { record = { element: target, keyframes: [] }; individualKeyframes.push(record); } record.keyframes.push(frame[selector]); } } } for (const { element: el, keyframes: kf } of individualKeyframes) { el.animate(kf, options); } } class RotateController { constructor() { this.timer = null; this.isToggleAnimating = false; this.currentScale = 1; this.currentAngle = 0; this.currentTranslateX = 0; this.currentTranslateY = 0; this.updateTimer = null; this.lastEnterTime = 0; this.animationCooldown = false; this.isDragging = false; this.dragStartX = 0; this.dragStartY = 0; this.translateStartX = 0; this.translateStartY = 0; this.dragLongPressTimer = null; this.isMouseDown = false; this.mouseDownTime = 0; this.longPressThreshold = 500; this.isLongPressReady = false; this.wasDragging = false; this.clickInterceptor = null; this.globalMouseMoveHandler = (e) => this.onGlobalMouseMove(e); this.globalMouseUpHandler = (e) => this.onGlobalMouseUp(e); this.init(); } init() { const toggle = document.querySelector('.bpx-player-ctrl-rotate .bpx-player-ctrl-btn-icon'); const panel = document.querySelector('.bpx-player-ctrl-rotate .bcmnp-rotate-box'); const scaleSlider = document.querySelector('#scale-slider'); const rotateSlider = document.querySelector('#rotate-slider'); const scaleValue = document.querySelector('#scale-value'); const rotateValue = document.querySelector('#rotate-value'); const scalePresets = document.querySelector('.bcmnp-scale-presets'); const rotatePresets = document.querySelector('.bcmnp-rotate-presets'); const rotateBtn = document.querySelector('.bpx-player-ctrl-rotate'); const resetBtn = document.querySelector('#reset-position-btn'); const resetAllBtn = document.querySelector('#reset-all-btn'); if (!toggle || !panel || !scaleSlider || !rotateSlider || !scaleValue || !rotateValue || !scalePresets || !rotatePresets || !rotateBtn || !resetBtn || !resetAllBtn) { console.error('元素未找到'); return; } this.toggle = toggle; this.panel = panel; this.scaleSlider = scaleSlider; this.rotateSlider = rotateSlider; this.scaleValue = scaleValue; this.rotateValue = rotateValue; this.scalePresets = scalePresets; this.rotatePresets = rotatePresets; this.rotateBtn = rotateBtn; this.resetBtn = resetBtn; this.resetAllBtn = resetAllBtn; this.rotateBtn.addEventListener('mouseenter', () => this.onMouseEnter()); this.rotateBtn.addEventListener('mouseleave', () => this.onMouseLeave()); this.panel.addEventListener('mouseenter', () => this.onPanelEnter()); this.panel.addEventListener('mouseleave', () => this.onPanelLeave()); this.scaleSlider.addEventListener('input', (e) => this.scaleSliderOnInput(e)); this.rotateSlider.addEventListener('input', (e) => this.rotateSliderOnInput(e)); this.scalePresets.addEventListener('click', (e) => this.scalePresetOnClick(e)); this.rotatePresets.addEventListener('click', (e) => this.rotatePresetOnClick(e)); this.resetBtn.addEventListener('click', () => this.resetPosition()); this.resetAllBtn.addEventListener('click', () => this.resetAll()); this.scaleSlider.addEventListener('wheel', (e) => this.handleWheel(e, this.scaleSlider, 1)); this.rotateSlider.addEventListener('wheel', (e) => this.handleWheel(e, this.rotateSlider, 1)); const scaleContainer = this.scaleSlider.closest('.bcmnp-slider-container'); const rotateContainer = this.rotateSlider.closest('.bcmnp-slider-container'); if (scaleContainer) scaleContainer.addEventListener('wheel', (e) => this.handleWheel(e, this.scaleSlider, 1)); if (rotateContainer) rotateContainer.addEventListener('wheel', (e) => this.handleWheel(e, this.rotateSlider, 1)); this.createClickInterceptor(); this.initDragEvents(); } createClickInterceptor() { this.clickInterceptor = document.createElement('div'); this.clickInterceptor.className = 'bcmnp-click-interceptor'; this.clickInterceptor.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); return false; }, true); this.clickInterceptor.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); return false; }, true); this.clickInterceptor.addEventListener('mouseup', (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); return false; }, true); document.body.appendChild(this.clickInterceptor); } initDragEvents() { const videoWrap = document.querySelector('.bpx-player-video-wrap'); if (!videoWrap) { setTimeout(() => this.initDragEvents(), 1000); return; } const video = videoWrap.querySelector('video'); if (video) { video.addEventListener('click', (e) => this.onVideoClick(e), true); video.addEventListener('mousedown', (e) => this.onVideoMouseDown(e), true); video.addEventListener('mouseup', (e) => this.onVideoMouseUp(e), true); video.addEventListener('mousemove', (e) => this.onVideoMouseMove(e), true); } videoWrap.addEventListener('mousedown', (e) => this.onVideoMouseDown(e), true); videoWrap.addEventListener('mouseup', (e) => this.onVideoMouseUp(e), true); videoWrap.addEventListener('mousemove', (e) => this.onVideoMouseMove(e), true); videoWrap.addEventListener('dragstart', (e) => e.preventDefault()); document.addEventListener('mouseup', this.globalMouseUpHandler, true); } onVideoClick(e) { const clickDuration = Date.now() - this.mouseDownTime; if (clickDuration < this.longPressThreshold && !this.isDragging && !this.wasDragging) { return; } e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); return false; } onVideoMouseDown(e) { if (e.button !== 0) return; this.isLongPressReady = false; this.isDragging = false; this.wasDragging = false; this.isMouseDown = true; this.mouseDownTime = Date.now(); if (this.dragLongPressTimer) { clearTimeout(this.dragLongPressTimer); this.dragLongPressTimer = null; } this.dragStartX = e.clientX; this.dragStartY = e.clientY; this.translateStartX = this.currentTranslateX; this.translateStartY = this.currentTranslateY; this.dragLongPressTimer = setTimeout(() => { if (this.isMouseDown) { this.isLongPressReady = true; document.addEventListener('mousemove', this.globalMouseMoveHandler, true); } }, this.longPressThreshold); } onVideoMouseMove(e) { if (this.isDragging) { e.preventDefault(); e.stopPropagation(); } } onVideoMouseUp(e) { if (e.button !== 0) return; this.endDrag(e); } onGlobalMouseUp(e) { if (e.button !== 0) return; this.endDrag(e); } endDrag(e) { this.isMouseDown = false; if (this.dragLongPressTimer) { clearTimeout(this.dragLongPressTimer); this.dragLongPressTimer = null; } if (this.isDragging) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.wasDragging = true; this.isDragging = false; this.isLongPressReady = false; const videoWrap = document.querySelector('.bpx-player-video-wrap'); if (videoWrap) { videoWrap.classList.remove('bcmnp-dragging-video'); } document.body.classList.remove('bcmnp-dragging-active'); document.removeEventListener('mousemove', this.globalMouseMoveHandler, true); if (this.clickInterceptor) { setTimeout(() => { this.clickInterceptor.classList.remove('active'); }, 200); } setTimeout(() => { this.wasDragging = false; }, 300); } else { this.isLongPressReady = false; document.removeEventListener('mousemove', this.globalMouseMoveHandler, true); } } onGlobalMouseMove(e) { if (!this.isLongPressReady || !this.isMouseDown) { return; } e.preventDefault(); e.stopPropagation(); const deltaX = Math.abs(e.clientX - this.dragStartX); const deltaY = Math.abs(e.clientY - this.dragStartY); if (deltaX > 3 || deltaY > 3) { if (!this.isDragging) { this.isDragging = true; const videoWrap = document.querySelector('.bpx-player-video-wrap'); if (videoWrap) { const transform = videoWrap.style.transform; const translateMatch = transform.match(/translate\(([-\d.]+)px,\s*([-\d.]+)px\)/); if (translateMatch) { this.translateStartX = parseFloat(translateMatch[1]) || 0; this.translateStartY = parseFloat(translateMatch[2]) || 0; } document.body.classList.add('bcmnp-dragging-active'); videoWrap.classList.add('bcmnp-dragging-video'); if (this.clickInterceptor) { this.clickInterceptor.classList.add('active'); } } } if (this.isDragging) { const moveDeltaX = e.clientX - this.dragStartX; const moveDeltaY = e.clientY - this.dragStartY; const rotatedDelta = rotatePoint(moveDeltaX, moveDeltaY, -this.currentAngle); const moveFactor = 1 / this.currentScale; const adjustedDeltaX = rotatedDelta.x * moveFactor; const adjustedDeltaY = rotatedDelta.y * moveFactor; const newTranslateX = this.translateStartX + adjustedDeltaX; const newTranslateY = this.translateStartY + adjustedDeltaY; this.currentTranslateX = newTranslateX; this.currentTranslateY = newTranslateY; this.updateVideoTransform(); } } } resetPosition() { this.currentTranslateX = 0; this.currentTranslateY = 0; this.updateVideoTransform(); } resetAll() { this.currentScale = 1; this.currentAngle = 0; this.currentTranslateX = 0; this.currentTranslateY = 0; this.scaleSlider.value = '100'; this.scaleValue.textContent = '100%'; this.rotateSlider.value = '0'; this.rotateValue.textContent = '0°'; this.updateActivePresets(); this.updateVideoTransform(); } handleWheel(e, slider, step = 1) { e.preventDefault(); e.stopPropagation(); const delta = e.deltaY > 0 ? -step : step; let newValue = parseInt(slider.value, 10) + delta; const min = parseInt(slider.min, 10); const max = parseInt(slider.max, 10); newValue = Math.max(min, Math.min(max, newValue)); slider.value = newValue.toString(); const inputEvent = new Event('input', { bubbles: true }); slider.dispatchEvent(inputEvent); } onMouseEnter() { const now = Date.now(); if (this.timer) { clearTimeout(this.timer); this.timer = null; } this.showPanel(); if (!this.animationCooldown || (now - this.lastEnterTime > 1000)) { this.playAnimation(); this.lastEnterTime = now; this.animationCooldown = true; setTimeout(() => { this.animationCooldown = false; }, 1500); } } onMouseLeave() { this.startHideTimer(); } onPanelEnter() { if (this.timer) { clearTimeout(this.timer); this.timer = null; } } onPanelLeave() { this.startHideTimer(); } startHideTimer() { if (this.timer) { clearTimeout(this.timer); } this.timer = setTimeout(() => { this.hidePanel(); this.timer = null; }, 300); } playAnimation() { if (!this.isToggleAnimating) { this.isToggleAnimating = true; animateGroup(this.toggle, rotateToggleKeyframes, { duration: 800, easing: 'ease' }); setTimeout(() => { this.isToggleAnimating = false; }, 1000); } } showPanel() { if (this.panel.style.display === 'flex') return; this.panel.style.display = 'flex'; this.updatePanelPosition(); } hidePanel() { if (this.panel.style.display === 'none') return; this.panel.style.display = 'none'; } updatePanelPosition() { const toggleRect = this.rotateBtn.getBoundingClientRect(); const panelRect = this.panel.getBoundingClientRect(); const screenType = document.querySelector('.bpx-player-container')?.dataset.screen ?? 'normal'; this.panel.style.bottom = (screenType === 'full' || screenType === 'web') ? '74px' : '41px'; this.panel.style.right = `${(toggleRect.width - panelRect.width) / 2}px`; } scaleSliderOnInput(e) { const value = parseInt(e.target.value, 10); this.scaleValue.textContent = `${value}%`; this.currentScale = value / 100; this.updateActivePresets(); if (this.updateTimer) clearTimeout(this.updateTimer); this.updateTimer = setTimeout(() => this.updateVideoTransform(), 50); } rotateSliderOnInput(e) { const value = parseInt(e.target.value, 10); this.rotateValue.textContent = `${value}°`; this.currentAngle = value; this.updateActivePresets(); if (this.updateTimer) clearTimeout(this.updateTimer); this.updateTimer = setTimeout(() => this.updateVideoTransform(), 50); } scalePresetOnClick(e) { const target = e.target; if (!target.classList.contains('bcmnp-preset-btn')) return; const scaleStr = target.dataset.scale; if (!scaleStr) return; const scale = parseFloat(scaleStr); const percent = Math.round(scale * 100); this.scaleSlider.value = percent.toString(); this.scaleValue.textContent = `${percent}%`; this.currentScale = scale; this.updateActivePresets(); this.updateVideoTransform(); } rotatePresetOnClick(e) { const target = e.target; if (!target.classList.contains('bcmnp-preset-btn')) return; const angleStr = target.dataset.angle; if (!angleStr) return; const angle = parseInt(angleStr, 10); this.rotateSlider.value = angle.toString(); this.rotateValue.textContent = `${angle}°`; this.currentAngle = angle; this.updateActivePresets(); this.updateVideoTransform(); } updateActivePresets() { this.scalePresets.querySelectorAll('.bcmnp-preset-btn').forEach(btn => { const scaleStr = btn.dataset.scale; if (scaleStr) { const scale = parseFloat(scaleStr); btn.classList.toggle('active', Math.abs(scale - this.currentScale) < 0.01); } }); this.rotatePresets.querySelectorAll('.bcmnp-preset-btn').forEach(btn => { const angleStr = btn.dataset.angle; if (angleStr) { const angle = parseInt(angleStr, 10); btn.classList.toggle('active', angle === this.currentAngle); } }); } updateVideoTransform() { const video = document.querySelector('.bpx-player-video-wrap'); if (!video) { return; } let angle = this.currentAngle; const scale = this.currentScale; const W = 16, H = 9; const rad = angle * Math.PI / 180; const scaleX = W / (W * Math.abs(Math.cos(rad)) + H * Math.abs(Math.sin(rad))); const scaleY = H / (W * Math.abs(Math.sin(rad)) + H * Math.abs(Math.cos(rad))); const compositeScale = Math.min(scaleX, scaleY) * scale; if (angle === 90 || angle === 270 || angle === 180) angle += 0.0001; const oldTransform = video.style.transform || ''; const newTransform = `rotate(${angle}deg) scale(${compositeScale}) translate(${this.currentTranslateX}px, ${this.currentTranslateY}px)`; video.style.transform = newTransform; video.style.transformOrigin = 'center center'; if (!this.isDragging && oldTransform) { video.animate([ { transform: oldTransform }, { transform: newTransform } ], { duration: 300, easing: 'ease-in-out' }); } } } async function main() { try { const settingBtn = await waitUntilElementReady('.bpx-player-ctrl-btn.bpx-player-ctrl-setting'); const beginTime = performance.now(); insertHtmlAfterElement(settingBtn, rotateHtml); setTimeout(() => { window.rotateController = new RotateController(); const cost = (performance.now() - beginTime).toFixed(1); printVersion('1.4.0', cost); }, 500); } catch (error) { console.error('脚本初始化失败:', error); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', main); } else { main(); } })();