// ==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();
}
})();