// ==UserScript==
// @name B站视频缩放、旋转
// @namespace https://github.com/kqint
// @version 6.2.1
// @description 右下角悬停面板控制缩放(50%-350%)/旋转(0-359°),支持Alt+左键拖拽、Alt+滚轮缩放,快捷缩放/旋转按钮,独立重置,视频记忆,缩放Toast提示,支持直播
// @author kqint
// @license MIT
// @homepageURL https://github.com/kqint/bilibili-zoom-rotate
// @match https://www.bilibili.com/video/*
// @match https://www.bilibili.com/list/watchlater*
// @match https://www.bilibili.com/medialist/play/*
// @match https://www.bilibili.com/bangumi/play/*
// @match https://www.bilibili.com/list/*
// @match https://live.bilibili.com/*
// @match https://www.bilibili.com/cheese/play/*
// @icon https://www.bilibili.com/favicon.ico
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// ==/UserScript==
(function() {
'use strict';
if (window.__newBiliScriptLoaded) {
return;
}
window.__newBiliScriptLoaded = true;
// 快捷键配置
const shortcutConfig = {
dragModifierKey: 'altKey',
wheelZoomModifierKey: 'altKey',
};
// Alt+滚轮缩放配置(单位:百分比)
const wheelZoomConfig = {
stepPercent: 1,
};
// 默认配置
const defaultConfig = {
dragModifierKey: shortcutConfig.dragModifierKey,
wheelZoomModifierKey: shortcutConfig.wheelZoomModifierKey,
wheelZoomStepPercent: wheelZoomConfig.stepPercent,
};
let userConfig = {
dragModifierKey: GM_getValue('dragModifierKey', defaultConfig.dragModifierKey),
wheelZoomModifierKey: GM_getValue('wheelZoomModifierKey', defaultConfig.wheelZoomModifierKey),
wheelZoomStepPercent: GM_getValue('wheelZoomStepPercent', defaultConfig.wheelZoomStepPercent),
};
function getModifierDisplayName(key) {
const map = {
'altKey': 'Alt',
'ctrlKey': 'Ctrl',
'shiftKey': 'Shift',
'metaKey': 'Meta',
};
return map[key] || key;
}
GM_addStyle(`
.nbs-control-root {
position: relative;
user-select: none;
overflow: visible;
display: flex;
justify-content: center;
align-items: center;
color: #fff !important;
opacity: 1 !important;
}
.nbs-control-root .nbs-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
cursor: pointer;
transition: transform 0.2s ease;
color: inherit !important;
opacity: 1 !important;
}
.nbs-control-root .bpx-player-ctrl-btn-icon {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: inherit !important;
opacity: 1 !important;
}
.nbs-control-root .bpx-common-svg-icon {
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
color: inherit !important;
opacity: 1 !important;
}
.nbs-control-root .nbs-toggle-btn svg {
display: block;
width: 100%;
height: 100%;
max-width: 24px;
max-height: 24px;
color: inherit !important;
opacity: 1 !important;
}
.nbs-control-root.nbs-compact-mode .nbs-toggle-btn svg {
max-width: 20px;
max-height: 20px;
}
.bpx-player-container[data-screen=full] .nbs-control-root .nbs-toggle-btn,
.bpx-player-container[data-screen=web] .nbs-control-root .nbs-toggle-btn {
position: relative;
top: -5px;
}
.bpx-player-container[data-screen=full] .nbs-control-root .nbs-toggle-btn svg,
.bpx-player-container[data-screen=web] .nbs-control-root .nbs-toggle-btn svg {
max-width: 26px;
max-height: 26px;
filter: drop-shadow(0 0 0 currentColor);
}
.nbs-control-root:hover,
.nbs-control-root:focus-within,
.nbs-control-root .nbs-toggle-btn:hover,
.nbs-control-root .nbs-toggle-btn:focus {
color: #fff !important;
opacity: 1 !important;
}
.nbs-control-root .nbs-panel {
box-sizing: border-box;
position: absolute;
right: 50%;
transform: translateX(50%);
bottom: 41px;
display: none;
flex-direction: column;
gap: 8px;
min-width: 260px;
padding: 10px 12px;
border-radius: 8px;
color: #fff;
background: rgba(32, 32, 32, 0.92);
backdrop-filter: blur(4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
z-index: 20;
}
.nbs-control-root .nbs-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.nbs-control-root .nbs-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.88);
line-height: 1;
}
.nbs-control-root .nbs-scale-line {
display: flex;
align-items: center;
gap: 8px;
}
.nbs-control-root .nbs-scale-slider {
flex: 1;
accent-color: var(--bpx-primary-color, #00A1D6);
cursor: pointer;
}
.nbs-control-root .nbs-scale-value {
min-width: 44px;
font-size: 12px;
text-align: right;
color: #fff;
}
/* 旋转按钮区域 */
.nbs-control-root .nbs-rotate-items,
.nbs-control-root .nbs-scale-items {
display: flex;
justify-content: center;
gap: 12px;
margin: 4px 0;
}
.nbs-control-root .nbs-rotate-slider-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.nbs-control-root .nbs-rotate-slider {
flex: 1;
accent-color: var(--bpx-primary-color, #00A1D6);
cursor: pointer;
}
.nbs-control-root .nbs-rotate-degree {
min-width: 44px;
font-size: 12px;
text-align: right;
color: #fff;
}
.nbs-control-root .nbs-rotate-btn {
flex: 0 0 auto;
width: 48px;
height: 28px;
border: none;
border-radius: 6px;
color: #fff;
font-size: 12px;
cursor: pointer;
background: rgba(255, 255, 255, 0.24);
transition: background-color 0.18s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.nbs-control-root .nbs-rotate-btn:hover {
background: rgba(255, 255, 255, 0.36);
}
.nbs-control-root .nbs-rotate-btn.checked {
background: var(--bpx-primary-color, #00A1D6);
}
/* 快捷缩放按钮 */
.nbs-control-root .nbs-scale-btn {
flex: 0 0 auto;
width: 48px;
height: 28px;
border: none;
border-radius: 6px;
color: #fff;
font-size: 12px;
cursor: pointer;
background: rgba(255, 255, 255, 0.24);
transition: background-color 0.18s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.nbs-control-root .nbs-scale-btn:hover {
background: rgba(255, 255, 255, 0.36);
}
.nbs-control-root .nbs-scale-btn.checked {
background: var(--bpx-primary-color, #00A1D6);
}
/* 独立重置图标按钮 */
.nbs-control-root .nbs-reset-icon {
flex: 0 0 auto;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.18s ease, background 0.18s ease;
padding: 2px;
}
.nbs-control-root .nbs-reset-icon:hover {
color: #fff;
background: rgba(255, 255, 255, 0.15);
}
.nbs-control-root .nbs-tip {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
line-height: 1.3;
text-align: left;
margin: 0 2px;
background: rgba(255, 255, 255, 0.06);
border-radius: 4px;
}
/* 视频记忆开关 */
.nbs-control-root .nbs-memory-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
}
.nbs-control-root .nbs-memory-row label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 12px;
color: rgba(255, 255, 255, 0.88);
}
.nbs-control-root .nbs-memory-toggle {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
flex-shrink: 0;
}
.nbs-control-root .nbs-memory-toggle input {
opacity: 0;
width: 0;
height: 0;
}
.nbs-control-root .nbs-memory-slider {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.2);
border-radius: 20px;
transition: background 0.2s ease;
}
.nbs-control-root .nbs-memory-slider::before {
content: '';
position: absolute;
width: 16px;
height: 16px;
left: 2px;
top: 2px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s ease;
}
.nbs-control-root .nbs-memory-toggle input:checked + .nbs-memory-slider {
background: var(--bpx-primary-color, #00A1D6);
}
.nbs-control-root .nbs-memory-toggle input:checked + .nbs-memory-slider::before {
transform: translateX(16px);
}
/* 独立还原按钮 */
.nbs-reset-btn-global {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
height: 44px;
padding: 0 28px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 36px;
font-size: 18px;
font-weight: 600;
letter-spacing: 0.5px;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
color: #EFEFEF;
cursor: pointer;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
z-index: 22;
white-space: nowrap;
transition: opacity 0.2s ease, transform 0.2s ease, background 0.2s ease;
opacity: 0;
pointer-events: none;
}
.nbs-reset-btn-global.show {
opacity: 1;
pointer-events: auto;
}
.nbs-reset-btn-global:hover {
background: rgba(0, 0, 0, 0.9);
color: #a7e9ff;
transform: translateX(-50%) scale(1.02);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
}
/* 非全屏紧凑模式缩小还原按钮 */
.nbs-reset-btn-global.nbs-compact {
height: 28px;
padding: 0 14px;
font-size: 13px;
border-radius: 28px;
}
.nbs-reset-btn-global.nbs-compact:hover {
transform: translateX(-50%) scale(1.05);
}
.nbs-reset-btn-global.nbs-hidden-by-player,
.nbs-toast.nbs-hidden-by-player {
opacity: 0 !important;
pointer-events: none !important;
}
/* 播放器控制栏隐藏时,还原按钮也隐藏 */
.bpx-player-container[data-refer=hide] .nbs-reset-btn-global,
.bpx-player-container[data-refer=hide] .nbs-toast {
opacity: 0 !important;
pointer-events: none !important;
}
/* Toast 提示 - 顶部居中 */
.nbs-toast {
position: absolute;
top: 12%;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(16px);
color: white;
padding: 12px 28px;
border-radius: 40px;
font-size: 18px;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.1);
pointer-events: none;
z-index: 100;
white-space: nowrap;
opacity: 0;
transition: opacity 0.2s ease;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
/* 非全屏紧凑模式缩小Toast */
.nbs-toast.nbs-compact {
transform: translateX(-50%) scale(0.85);
font-size: 14px;
padding: 10px 24px;
}
.nbs-toast.show {
opacity: 1;
}
/* 强制显示类 */
.nbs-toast.nbs-force-show {
opacity: 1 !important;
pointer-events: none !important;
}
/* 拖拽时临时禁用视频上的点击事件 */
.bpx-player-video-wrap.nbs-dragging {
cursor: grabbing !important;
}
.bpx-player-video-wrap.nbs-dragging video {
pointer-events: none !important;
}
/* 直播页面视频包裹层拖拽 */
.nbs-live-video-wrap.nbs-dragging {
cursor: grabbing !important;
}
.nbs-live-video-wrap.nbs-dragging video {
pointer-events: none !important;
}
/* 直播页面控制按钮定位 - 浮动按钮 */
.nbs-control-root.nbs-live-control {
position: absolute !important;
bottom: 55px !important;
right: 116px !important;
z-index: 20 !important;
width: 38px !important;
height: 38px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
background: rgba(0, 0, 0, 0.55) !important;
border-radius: 50% !important;
backdrop-filter: blur(4px) !important;
color: #fff !important;
cursor: pointer !important;
}
.nbs-control-root.nbs-live-control.nbs-hidden-by-player {
opacity: 0 !important;
pointer-events: none !important;
}
.nbs-control-root.nbs-live-control .nbs-panel {
bottom: 46px !important;
}
`);
const state = {
scalePercent: 100,
rotation: 0,
// 使用百分比存储偏移(-1 到 1 范围,表示视频容器宽高的百分比)
offsetX: 0,
offsetY: 0,
isDragging: false,
// 拖拽开始时的初始偏移百分比
dragStartOffsetX: 0,
dragStartOffsetY: 0,
// 拖拽开始时的鼠标位置
dragStartClientX: 0,
dragStartClientY: 0,
justDragged: false,
};
const refs = {
root: null,
toggleBtn: null,
panel: null,
scaleSlider: null,
scaleValue: null,
scaleButtons: [],
scaleReset: null,
rotateButtons: [],
rotateSlider: null,
rotateDegree: null,
rotateReset: null,
resetButton: null,
toast: null,
tipText: null,
memoryToggle: null,
};
let videoWrap = null;
let hideTimer = 0;
let syncTimer = 0;
let observer = null;
let toastTimer = null;
let lastSyncedContainer = null;
let controlBarObserver = null;
let observedControlContainer = null;
let controlVisibilityRaf = 0;
// 全屏切换防抖
let screenModeChangeTimer = null;
let isScreenModeChanging = false;
let screenModeObserver = null;
let observedScreenModeContainer = null;
let currentVideoId = null;
let saveStateTimer = null;
let videoMemoryEnabled = localStorage.getItem('nbs_memoryEnabled') !== 'false';
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function isLivePage() {
return location.hostname === 'live.bilibili.com';
}
function getPlayerContainer() {
if (isLivePage()) {
return document.querySelector('#live-player');
}
return document.querySelector('.bpx-player-container');
}
function getVideoWrap() {
if (isLivePage()) {
return getLiveVideoWrap();
}
return document.querySelector('.bpx-player-video-wrap');
}
function getLiveVideoWrap() {
const container = getPlayerContainer();
if (!container) return null;
// 复用已创建的包裹层
let wrap = container.querySelector('.nbs-live-video-wrap');
if (wrap) return wrap;
const video = container.querySelector('video');
if (!video) return null;
wrap = document.createElement('div');
wrap.className = 'nbs-live-video-wrap';
wrap.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;overflow:hidden;z-index:2';
video.parentNode.insertBefore(wrap, video);
wrap.appendChild(video);
return wrap;
}
function getMediaElement() {
if (videoWrap) {
const wrappedVideo = videoWrap.querySelector('video');
if (wrappedVideo) return wrappedVideo;
}
return document.querySelector('video');
}
function getVideoId() {
const path = window.location.pathname;
const urlParams = new URLSearchParams(window.location.search);
let bvid = null;
const pathMatch = path.match(/\/(?:video|list\/watchlater)\/(BV[^/?#]+)/);
if (pathMatch) {
bvid = pathMatch[1];
}
if (!bvid) {
bvid = urlParams.get('bvid');
}
if (bvid) {
const p = urlParams.get('p');
return p ? `${bvid}-p${p}` : bvid;
}
return path;
}
function saveVideoState() {
if (!videoMemoryEnabled || !currentVideoId) return;
const key = 'nbs_videoState_' + currentVideoId;
if (isDefaultState()) {
localStorage.removeItem(key);
return;
}
localStorage.setItem(key, JSON.stringify({
scalePercent: state.scalePercent,
rotation: state.rotation,
offsetX: state.offsetX,
offsetY: state.offsetY,
}));
}
function scheduleSaveState() {
if (saveStateTimer) clearTimeout(saveStateTimer);
saveStateTimer = setTimeout(() => {
saveStateTimer = 0;
saveVideoState();
}, 500);
}
function loadVideoState(videoId) {
if (!videoId) return false;
try {
const raw = localStorage.getItem('nbs_videoState_' + videoId);
if (!raw) return false;
const saved = JSON.parse(raw);
if (!saved || typeof saved.scalePercent !== 'number') return false;
state.scalePercent = clamp(Math.round(saved.scalePercent), 50, 350);
state.rotation = ((saved.rotation % 360) + 360) % 360;
state.offsetX = clamp(Number(saved.offsetX) || 0, -1, 1);
state.offsetY = clamp(Number(saved.offsetY) || 0, -1, 1);
return true;
} catch (e) {
return false;
}
}
function getControlVisibilityElement() {
if (isLivePage()) return null;
const container = getPlayerContainer();
if (!container) return null;
return container.querySelector('.bpx-player-control-bottom')
|| container.querySelector('.bpx-player-control-wrap')
|| container.querySelector('.bpx-player-control-entity');
}
function isElementActuallyVisible(element) {
if (!element || !element.isConnected) return false;
const style = getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden') {
return false;
}
if (Number(style.opacity || '1') <= 0.05) {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function syncPlayerOverlayVisibility() {
const container = getPlayerContainer();
const controlBar = getControlVisibilityElement();
const shouldHide = Boolean(
container
&& (
container.dataset.refer === 'hide'
|| (controlBar && !isElementActuallyVisible(controlBar))
)
);
// 还原按钮悬浮时不隐藏
const isResetButtonHovered = refs.resetButton && refs.resetButton.dataset.hover === 'true';
if (refs.resetButton) {
refs.resetButton.classList.toggle('nbs-hidden-by-player', shouldHide && !isResetButtonHovered);
}
// Toast 强制显示时不隐藏
if (refs.toast && !refs.toast.classList.contains('nbs-force-show')) {
refs.toast.classList.toggle('nbs-hidden-by-player', shouldHide);
}
}
function scheduleOverlayVisibilitySync() {
if (controlVisibilityRaf) return;
controlVisibilityRaf = requestAnimationFrame(() => {
controlVisibilityRaf = 0;
syncPlayerOverlayVisibility();
});
}
function getRotateFitScale(rotationDeg) {
const angle = ((rotationDeg % 360) + 360) % 360;
if (angle === 0) {
return 1;
}
const media = getMediaElement();
const width = media && media.videoWidth ? media.videoWidth : (videoWrap ? videoWrap.clientWidth : 16);
const height = media && media.videoHeight ? media.videoHeight : (videoWrap ? videoWrap.clientHeight : 9);
if (!width || !height) {
return 1;
}
const rad = angle * Math.PI / 180;
const fitX = width / (width * Math.abs(Math.cos(rad)) + height * Math.abs(Math.sin(rad)));
const fitY = height / (width * Math.abs(Math.sin(rad)) + height * Math.abs(Math.cos(rad)));
return Math.min(fitX, fitY);
}
function getEffectiveScale() {
return getRotateFitScale(state.rotation) * (state.scalePercent / 100);
}
function isDefaultState() {
return state.scalePercent === 100 && state.rotation === 0 && state.offsetX === 0 && state.offsetY === 0;
}
function updateResetButtonVisibility() {
if (!refs.resetButton) return;
if (isDefaultState()) {
refs.resetButton.classList.remove('show');
} else {
refs.resetButton.classList.add('show');
}
scheduleOverlayVisibilitySync();
}
function updateResetButtonPosition() {
if (!refs.resetButton) return;
const container = getPlayerContainer();
if (!container) return;
if (isLivePage()) {
refs.resetButton.style.bottom = '120px';
return;
}
const screenMode = container.dataset.screen || 'normal';
// 计算还原按钮位置:基于播放器高度的百分比
const containerHeight = container.clientHeight || 600;
if (screenMode === 'full' || screenMode === 'web') {
// 全屏/网页全屏
refs.resetButton.style.bottom = Math.round(containerHeight * 0.12) + 'px';
} else {
// 非全屏
refs.resetButton.style.bottom = '120px';
}
}
function updatePanelPosition() {
if (!refs.panel || !refs.toggleBtn) return;
if (isLivePage()) {
refs.panel.style.bottom = '46px';
return;
}
const container = getPlayerContainer();
const screenMode = container ? container.dataset.screen : 'normal';
refs.panel.style.bottom = (screenMode === 'full' || screenMode === 'web') ? '74px' : '41px';
}
function updateScaleUI() {
if (refs.scaleSlider) {
refs.scaleSlider.value = String(state.scalePercent);
}
if (refs.scaleValue) {
refs.scaleValue.textContent = state.scalePercent + '%';
}
refs.scaleButtons.forEach((btn) => {
const s = Number(btn.dataset.scale);
btn.classList.toggle('checked', s === state.scalePercent);
});
}
function updateRotateUI() {
refs.rotateButtons.forEach((button) => {
const angle = Number(button.dataset.angle);
button.classList.toggle('checked', angle === state.rotation);
});
if (refs.rotateSlider) {
refs.rotateSlider.value = String(state.rotation);
}
if (refs.rotateDegree) {
refs.rotateDegree.textContent = state.rotation + '°';
}
}
function showToast(message) {
if (!refs.toast) return;
if (toastTimer) clearTimeout(toastTimer);
// 强制显示模式:添加强制显示类,忽略播放器隐藏状态
refs.toast.classList.add('nbs-force-show');
refs.toast.classList.remove('nbs-hidden-by-player');
refs.toast.textContent = message;
refs.toast.classList.add('show');
toastTimer = setTimeout(() => {
if (refs.toast) {
refs.toast.classList.remove('show');
refs.toast.classList.remove('nbs-force-show');
// 恢复同步状态检查
scheduleOverlayVisibilitySync();
}
}, 1500);
}
function applyTransform() {
if (!videoWrap) return;
const effectiveScale = getEffectiveScale();
// 将百分比偏移转换为像素值(基于视频容器当前尺寸)
const containerWidth = videoWrap.clientWidth || 1;
const containerHeight = videoWrap.clientHeight || 1;
const pixelOffsetX = state.offsetX * containerWidth;
const pixelOffsetY = state.offsetY * containerHeight;
videoWrap.style.transformOrigin = 'center center';
// 全屏切换期间禁用过渡动画以提升性能
const useTransition = !state.isDragging && !isScreenModeChanging;
videoWrap.style.transition = useTransition ? 'transform 0.22s ease' : 'none';
videoWrap.style.transform = `translate(${pixelOffsetX}px, ${pixelOffsetY}px) rotate(${state.rotation}deg) scale(${effectiveScale})`;
updateScaleUI();
updateRotateUI();
updateResetButtonVisibility();
updateResetButtonPosition();
}
function setScale(nextScale) {
const newScale = clamp(Math.round(nextScale), 50, 350);
if (state.scalePercent === newScale) return;
state.scalePercent = newScale;
applyTransform();
scheduleSaveState();
showToast(`缩放: ${state.scalePercent}%`);
}
function setRotation(nextRotation) {
let normalized = ((nextRotation % 360) + 360) % 360;
normalized = Math.round(normalized);
if (state.rotation === normalized) return;
state.rotation = normalized;
applyTransform();
scheduleSaveState();
showToast(`旋转: ${state.rotation}°`);
}
function resetScale() {
if (state.scalePercent === 100) return;
state.scalePercent = 100;
applyTransform();
scheduleSaveState();
showToast('缩放已重置');
}
function resetRotation() {
if (state.rotation === 0) return;
state.rotation = 0;
applyTransform();
scheduleSaveState();
showToast('旋转已重置');
}
function resetTransform() {
if (isDefaultState()) return;
state.scalePercent = 100;
state.rotation = 0;
state.offsetX = 0;
state.offsetY = 0;
applyTransform();
scheduleSaveState();
showToast('已还原');
}
function clearHideTimer() {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = 0;
}
}
function showPanel() {
if (!refs.panel) return;
clearHideTimer();
updatePanelPosition();
refs.panel.style.display = 'flex';
}
function scheduleHidePanel() {
clearHideTimer();
hideTimer = setTimeout(() => {
if (refs.panel) refs.panel.style.display = 'none';
}, 180);
}
// 快捷键 + 鼠标滚轮控制缩放
function onWheel(event) {
// 检查是否按住配置的快捷键
if (!event[userConfig.wheelZoomModifierKey]) return;
// 检查目标是否在视频区域内
const target = event.target;
if (!videoWrap || !videoWrap.contains(target)) return;
// 阻止默认滚轮行为(页面滚动)
event.preventDefault();
event.stopPropagation();
// 根据滚轮方向和配置精度调整缩放
const step = Math.max(1, Math.round(Number(userConfig.wheelZoomStepPercent) || 1));
const delta = event.deltaY > 0 ? -step : step;
const newScale = state.scalePercent + delta;
setScale(newScale);
}
function onMouseMove(event) {
if (!state.isDragging || !videoWrap) return;
// 计算鼠标移动的像素差值
const deltaX = event.clientX - state.dragStartClientX;
const deltaY = event.clientY - state.dragStartClientY;
// 转换为百分比偏移(基于当前视频容器尺寸)
const containerWidth = videoWrap.clientWidth || 1;
const containerHeight = videoWrap.clientHeight || 1;
state.offsetX = state.dragStartOffsetX + deltaX / containerWidth;
state.offsetY = state.dragStartOffsetY + deltaY / containerHeight;
applyTransform();
}
function stopDragging() {
if (!state.isDragging) return;
state.isDragging = false;
state.justDragged = true;
if (videoWrap) {
videoWrap.classList.remove('nbs-dragging');
}
applyTransform();
scheduleSaveState();
setTimeout(() => {
state.justDragged = false;
}, 100);
}
function onMouseUp(event) {
if (state.isDragging) {
stopDragging();
event.stopPropagation();
event.preventDefault();
event.stopImmediatePropagation();
}
}
function onVideoMouseDown(event) {
if (event.button !== 0) return;
if (!event[userConfig.dragModifierKey]) return;
state.isDragging = true;
// 记录拖拽开始时的偏移百分比和鼠标位置
state.dragStartOffsetX = state.offsetX;
state.dragStartOffsetY = state.offsetY;
state.dragStartClientX = event.clientX;
state.dragStartClientY = event.clientY;
if (videoWrap) {
videoWrap.classList.add('nbs-dragging');
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
}
// 捕获阶段的点击拦截,彻底阻止播放/暂停
function onCaptureClick(event) {
if (state.justDragged || state.isDragging) {
event.stopPropagation();
event.preventDefault();
event.stopImmediatePropagation();
}
}
// 根据播放器模式更新所有元素的紧凑类
function updateCompactMode() {
let isCompact;
if (isLivePage()) {
isCompact = !document.fullscreenElement;
} else {
const container = getPlayerContainer();
const screenMode = container ? container.dataset.screen : 'normal';
isCompact = (screenMode !== 'full' && screenMode !== 'web');
}
// 控制面板根元素
if (refs.root) {
if (isCompact) {
refs.root.classList.add('nbs-compact-mode');
} else {
refs.root.classList.remove('nbs-compact-mode');
}
}
// 还原按钮
if (refs.resetButton) {
if (isCompact) {
refs.resetButton.classList.add('nbs-compact');
} else {
refs.resetButton.classList.remove('nbs-compact');
}
updateResetButtonPosition(); // 同时更新位置
}
// Toast
if (refs.toast) {
if (isCompact) {
refs.toast.classList.add('nbs-compact');
} else {
refs.toast.classList.remove('nbs-compact');
}
}
}
// 监听播放器模式变化
function initScreenModeObserver() {
const container = getPlayerContainer();
if (!container) return;
// 容器没变且已有 observer,跳过
if (observedScreenModeContainer === container && screenModeObserver) {
return;
}
// 断开旧的 observer
if (screenModeObserver) {
screenModeObserver.disconnect();
}
observedScreenModeContainer = container;
screenModeObserver = new MutationObserver(() => {
// 防抖处理:全屏切换期间标记状态,禁用过渡动画
if (screenModeChangeTimer) {
clearTimeout(screenModeChangeTimer);
}
isScreenModeChanging = true;
// 立即应用一次transform(无过渡)
applyTransform();
// 延迟更新UI,等待全屏动画完成
screenModeChangeTimer = setTimeout(() => {
isScreenModeChanging = false;
applyTransform();
updateCompactMode();
updatePanelPosition();
updateResetButtonPosition();
}, 150);
});
screenModeObserver.observe(container, { attributes: true, attributeFilter: ['data-screen'] });
updateCompactMode();
updateResetButtonPosition();
}
// 监听播放器控制栏显隐状态,同步控制还原按钮显隐
function initControlBarObserver() {
const container = getPlayerContainer();
if (!container) return;
if (observedControlContainer === container && controlBarObserver) {
scheduleOverlayVisibilitySync();
return;
}
if (observedControlContainer && observedControlContainer !== container) {
observedControlContainer.removeEventListener('mousemove', scheduleOverlayVisibilitySync, true);
observedControlContainer.removeEventListener('mouseleave', scheduleOverlayVisibilitySync, true);
}
if (controlBarObserver) {
controlBarObserver.disconnect();
}
observedControlContainer = container;
controlBarObserver = new MutationObserver(() => {
scheduleOverlayVisibilitySync();
});
controlBarObserver.observe(container, {
attributes: true,
childList: true,
subtree: true,
attributeFilter: ['data-refer', 'class', 'style', 'data-state']
});
container.addEventListener('mousemove', scheduleOverlayVisibilitySync, true);
container.addEventListener('mouseleave', scheduleOverlayVisibilitySync, true);
scheduleOverlayVisibilitySync();
}
// 更新提示文本
function updateShortcutTip() {
if (!refs.tipText) return;
const dragMod = getModifierDisplayName(userConfig.dragModifierKey);
const wheelMod = getModifierDisplayName(userConfig.wheelZoomModifierKey);
const wheelStep = Math.max(1, Math.round(Number(userConfig.wheelZoomStepPercent) || 1));
refs.tipText.innerHTML = `拖拽移动:${dragMod} + 鼠标左键
滚轮缩放:${wheelMod} + 滚轮(步进 ${wheelStep}%)
记住视频状态:开启后,下次打开相同视频自动恢复上次的缩放/旋转/位置`;
}
// 挂载全局重置按钮
function mountGlobalResetButton() {
if (refs.resetButton && refs.resetButton.isConnected) return;
const container = getPlayerContainer();
if (!container) return;
if (getComputedStyle(container).position === 'static') {
container.style.position = 'relative';
}
const btn = document.createElement('button');
btn.className = 'nbs-reset-btn-global';
btn.textContent = '还原屏幕';
btn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
e.stopImmediatePropagation();
resetTransform();
});
// 鼠标悬浮时阻止隐藏
btn.addEventListener('mouseenter', () => {
btn.dataset.hover = 'true';
});
btn.addEventListener('mouseleave', () => {
delete btn.dataset.hover;
scheduleOverlayVisibilitySync();
});
container.appendChild(btn);
refs.resetButton = btn;
updateResetButtonVisibility();
updateCompactMode(); // 确保紧凑样式
updateResetButtonPosition(); // 更新位置
initControlBarObserver(); // 监听控制栏显隐
scheduleOverlayVisibilitySync();
}
function mountToast() {
if (refs.toast && refs.toast.isConnected) return;
const container = getPlayerContainer();
if (!container) return;
const toast = document.createElement('div');
toast.className = 'nbs-toast';
container.appendChild(toast);
refs.toast = toast;
updateCompactMode(); // 应用紧凑样式
scheduleOverlayVisibilitySync();
}
function bindVideoWrap() {
const nextWrap = getVideoWrap();
const newVideoId = getVideoId();
const wrapChanged = (nextWrap !== videoWrap);
const videoIdChanged = (newVideoId !== currentVideoId);
// 视频切换时,先保存旧视频状态(必须在检查 nextWrap 是否存在之前)
if (videoWrap && currentVideoId && (wrapChanged || videoIdChanged)) {
saveVideoState();
if (saveStateTimer) {
clearTimeout(saveStateTimer);
saveStateTimer = 0;
}
}
if (!nextWrap) return;
if (!wrapChanged && !videoIdChanged) return;
// 清理旧的 videoWrap
if (videoWrap && wrapChanged) {
videoWrap.removeEventListener('mousedown', onVideoMouseDown);
videoWrap.removeEventListener('wheel', onWheel);
videoWrap.classList.remove('nbs-dragging');
}
// 绑定新的 videoWrap
if (wrapChanged) {
videoWrap = nextWrap;
videoWrap.addEventListener('mousedown', onVideoMouseDown);
videoWrap.addEventListener('wheel', onWheel, { passive: false });
}
currentVideoId = newVideoId;
// 尝试加载保存的状态
if (!videoMemoryEnabled || !loadVideoState(newVideoId)) {
state.scalePercent = 100;
state.rotation = 0;
state.offsetX = 0;
state.offsetY = 0;
}
state.isDragging = false;
state.justDragged = false;
applyTransform();
}
function mountControlPanel() {
if (refs.root && refs.root.isConnected) {
refs.tipText = refs.root.querySelector('.nbs-tip');
if (refs.tipText) updateShortcutTip();
updateCompactMode(); // 确保模式类同步
return;
}
const container = getPlayerContainer();
if (!container) return;
let root;
// 直播页面:追加到播放器容器底部控制栏区域
if (isLivePage()) {
container.style.position = 'relative';
root = document.createElement('div');
root.id = 'nbs-control-root';
root.className = 'nbs-control-root nbs-live-control';
root.setAttribute('role', 'button');
container.appendChild(root);
// 基于无操作定时器自动隐藏(直播控制栏本身基于无操作隐藏)
let activityTimer;
const INACTIVITY_DELAY = 3000;
const onActivity = () => {
root.classList.remove('nbs-hidden-by-player');
clearTimeout(activityTimer);
activityTimer = setTimeout(() => root.classList.add('nbs-hidden-by-player'), INACTIVITY_DELAY);
};
container.addEventListener('mousemove', onActivity);
container.addEventListener('mouseenter', onActivity);
// 悬停在按钮/面板上时保持显示
root.addEventListener('mouseenter', () => clearTimeout(activityTimer));
root.addEventListener('mouseleave', () => {
clearTimeout(activityTimer);
activityTimer = setTimeout(() => root.classList.add('nbs-hidden-by-player'), INACTIVITY_DELAY);
});
// 初始显示后延迟隐藏
activityTimer = setTimeout(() => root.classList.add('nbs-hidden-by-player'), INACTIVITY_DELAY);
} else {
const anchor = container && container.querySelector('.bpx-player-ctrl-btn.bpx-player-ctrl-setting, .bpx-player-ctrl-setting');
if (!anchor || !anchor.parentElement) return;
root = document.createElement('div');
root.id = 'nbs-control-root';
root.className = 'bpx-player-ctrl-btn nbs-control-root';
root.setAttribute('role', 'button');
anchor.insertAdjacentElement('afterend', root);
}
root.innerHTML = `