// ==UserScript==
// @name B站视频进度条
// @namespace http://tampermonkey.net/
// @version 7.0
// @description 准确统计全部分P观看进度(含双重进度显示)
// @author FocusOn1
// @match https://www.bilibili.com/video/*
// @match https://greasyfork.org/zh-CN/scripts/505814-b%E7%AB%99%E8%A7%86%E9%A2%91%E8%BF%9B%E5%BA%A6%E6%9D%A1
// @match https://www.google.com/s2/favicons?sz=64&domain=bilibili.com
// @icon https://www.google.com/s2/favicons?sz=64&domain=greasyfork.org
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @run-at document-end
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// 配置
const config = {
size: 80,
position: {
x: GM_getValue('posX', 20),
y: GM_getValue('posY', 20)
},
colors: GM_getValue('colors', {
progress: '#FF9500',
progress2: '#00a1d6',
bg: '#eeeeee',
text: '#222'
}),
opacity: GM_getValue('opacity', 0.8),
lineWidth: 4,
updateInterval: 500,
zIndex: 2147483647,
fullscreenZIndex: 2147483646
};
// 状态
const state = {
container: null,
canvas: null,
tooltip: null,
video: null,
lastUpdate: 0,
partDurations: [],
currentPart: 1,
totalParts: 1,
isFullscreen: false
};
// 创建UI元素
function createUI() {
// 移除旧元素
const old = document.getElementById('bili-progress-container');
if (old) old.remove();
// 创建容器
state.container = document.createElement('div');
state.container.id = 'bili-progress-container';
updateContainerStyle();
// 创建Canvas - 使用2D渲染避免WebGL警告
state.canvas = document.createElement('canvas');
state.canvas.width = config.size;
state.canvas.height = config.size;
state.canvas.style.cssText = 'display: block; width: 100%; height: 100%;';
state.container.appendChild(state.canvas);
// 创建工具提示
state.tooltip = document.createElement('div');
state.tooltip.id = 'bili-progress-tooltip';
state.tooltip.style.cssText = `
position: absolute;
left: ${config.size + 10}px;
top: 50%;
transform: translateY(-50%);
background: white;
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
z-index: ${config.zIndex + 1};
font-size: 12px;
white-space: nowrap;
display: none;
min-width: 200px;
pointer-events: none;
`;
state.container.appendChild(state.tooltip);
// 添加事件监听
state.container.addEventListener('mouseenter', () => state.tooltip.style.display = 'block');
state.container.addEventListener('mouseleave', () => state.tooltip.style.display = 'none');
state.container.addEventListener('mousedown', startDrag);
state.container.addEventListener('contextmenu', showColorPicker);
// 添加到正确的位置
appendToCorrectParent();
}
// 拖动功能
function startDrag(e) {
if (e.button !== 0) return;
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const startLeft = parseInt(state.container.style.left);
const startBottom = parseInt(state.container.style.bottom);
function moveHandler(e) {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const newLeft = startLeft + dx;
const newBottom = startBottom - dy;
state.container.style.left = newLeft + 'px';
state.container.style.bottom = newBottom + 'px';
config.position.x = newLeft;
config.position.y = newBottom;
GM_setValue('posX', newLeft);
GM_setValue('posY', newBottom);
}
function upHandler() {
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
// 更新容器样式
function updateContainerStyle() {
if (!state.container) return;
state.container.style.cssText = `
position: ${state.isFullscreen ? 'absolute' : 'fixed'};
left: ${config.position.x}px;
bottom: ${config.position.y}px;
width: ${config.size}px;
height: ${config.size}px;
border-radius: 50%;
background: rgba(246, 248, 250, ${config.opacity});
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: ${state.isFullscreen ? config.fullscreenZIndex : config.zIndex};
cursor: move;
user-select: none;
pointer-events: auto;
`;
}
// 将元素添加到正确的父容器
function appendToCorrectParent() {
if (!state.container) return;
// 尝试获取全屏容器
const fullscreenContainer = getFullscreenContainer();
if (state.isFullscreen && fullscreenContainer) {
// 全屏模式下添加到播放器容器
if (state.container.parentNode !== fullscreenContainer) {
fullscreenContainer.appendChild(state.container);
}
} else {
// 其他模式下添加到body
if (state.container.parentNode !== document.body) {
document.body.appendChild(state.container);
}
}
}
// 获取全屏容器
function getFullscreenContainer() {
// B站新版全屏容器
const newFullscreenContainer = document.querySelector('.bpx-player-container.bpx-player-fullscreen, .bpx-player-video-wrap');
if (newFullscreenContainer) return newFullscreenContainer;
// 旧版全屏容器
return document.querySelector('.bilibili-player-video-wrap.bilibili-player-fullscreen');
}
// 检测全屏状态
function checkFullscreenStatus() {
const newStatus = document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement;
// 检查B站特定的全屏类
const bilibiliFullscreen = document.querySelector('.bpx-player-container.bpx-player-fullscreen, .bilibili-player-video-wrap.bilibili-player-fullscreen');
const shouldBeFullscreen = !!newStatus || !!bilibiliFullscreen;
if (shouldBeFullscreen !== state.isFullscreen) {
state.isFullscreen = shouldBeFullscreen;
handleFullscreenChange();
}
}
// 处理全屏变化
function handleFullscreenChange() {
if (!state.container) return;
updateContainerStyle();
appendToCorrectParent();
// 强制重绘
if (state.canvas) {
const progress = calculateDoubleProgress();
if (progress) {
drawDoubleProgress(progress.percent1, progress.percent2);
}
}
}
// 设置全屏监听器
function setupFullscreenListeners() {
const events = [
'fullscreenchange',
'webkitfullscreenchange',
'mozfullscreenchange',
'MSFullscreenChange'
];
events.forEach(event => {
document.addEventListener(event, checkFullscreenStatus, false);
});
// 初始检查
checkFullscreenStatus();
// 添加定时检查,确保捕获所有全屏变化
setInterval(checkFullscreenStatus, 1000);
}
// 绘制双重进度条
function drawDoubleProgress(percent1, percent2) {
if (!state.canvas) return;
const ctx = state.canvas.getContext('2d');
const center = config.size / 2;
const radius = center - 10;
const innerRadius = radius - 8;
ctx.clearRect(0, 0, config.size, config.size);
// 背景圆环
ctx.beginPath();
ctx.arc(center, center, radius, 0, Math.PI * 2);
ctx.strokeStyle = config.colors.bg;
ctx.lineWidth = config.lineWidth;
ctx.stroke();
// 主进度条(外圈)
ctx.beginPath();
ctx.arc(center, center, radius, -Math.PI/2, (percent1/100)*Math.PI*2 - Math.PI/2);
ctx.strokeStyle = config.colors.progress;
ctx.lineWidth = config.lineWidth;
ctx.stroke();
// 次进度条(内圈)
ctx.beginPath();
ctx.arc(center, center, innerRadius, -Math.PI/2, (percent2/100)*Math.PI*2 - Math.PI/2);
ctx.strokeStyle = config.colors.progress2;
ctx.lineWidth = config.lineWidth - 1;
ctx.stroke();
// 百分比文字
ctx.fillStyle = config.colors.text;
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${Math.min(100, percent1).toFixed(1)}%`, center, center);
}
// 获取当前分P信息
function getCurrentPartInfo() {
try {
// 从URL获取当前分P
const urlParams = new URLSearchParams(window.location.search);
const pParam = urlParams.get('p');
state.currentPart = pParam ? parseInt(pParam) : 1;
// 从页面元素获取总P数
const partInfoElement = document.querySelector(".part-info, .video-pod__header .left div, .video-info .p");
if (partInfoElement) {
const partText = partInfoElement.textContent.trim();
const match = partText.match(/(\d+)\/(\d+)/);
if (match) {
state.totalParts = parseInt(match[2]);
}
}
// 获取当前分P时长
const durationElement = document.querySelector(".bpx-player-duration-time");
let duration = state.video ? state.video.duration : 0;
if (durationElement) {
const durationText = durationElement.textContent.trim();
duration = parseDuration(durationText);
}
return duration;
} catch (e) {
console.error('获取分P信息失败:', e);
return 0;
}
}
// 解析时长
function parseDuration(text) {
const parts = text.split(':').reverse();
let seconds = 0;
if (parts[0]) seconds += parseInt(parts[0]) || 0;
if (parts[1]) seconds += (parseInt(parts[1]) || 0) * 60;
if (parts[2]) seconds += (parseInt(parts[2]) || 0) * 3600;
return seconds;
}
// 获取所有分P的时长
function fetchAllPartDurations() {
const aidMatch = window.location.pathname.match(/video\/(av\d+|BV\w+)/);
if (!aidMatch) return;
const bvid = aidMatch[1];
const apiUrl = `https://api.bilibili.com/x/player/pagelist?bvid=${bvid}&jsonp=jsonp`;
GM_xmlhttpRequest({
method: "GET",
url: apiUrl,
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
if (data.code === 0 && data.data && data.data.length > 0) {
state.partDurations = data.data.map(part => part.duration);
state.totalParts = data.data.length;
console.log('获取分P时长成功:', state.partDurations);
}
} catch (e) {
console.error('解析分P时长失败:', e);
}
},
onerror: function(error) {
console.error('获取分P时长失败:', error);
}
});
}
// 计算双重进度
function calculateDoubleProgress() {
if (!state.video) return null;
const now = Date.now();
if (now - state.lastUpdate < config.updateInterval) return null;
state.lastUpdate = now;
// 获取当前分P信息
const currentDuration = getCurrentPartInfo();
// 如果没有获取到所有分P时长,使用当前分P时长作为默认值
if (state.partDurations.length === 0) {
state.partDurations = Array(state.totalParts).fill(currentDuration);
}
// 确保当前分P在合理范围内
state.currentPart = Math.min(Math.max(1, state.currentPart), state.totalParts);
// 计算总时长和已观看时长
const totalDuration = state.partDurations.reduce((sum, duration) => sum + duration, 0);
let watchedBeforeCurrent = 0;
// 计算之前所有分P的总时长
for (let i = 0; i < state.currentPart - 1; i++) {
watchedBeforeCurrent += state.partDurations[i] || 0;
}
// 主进度:(当前+之前)/全部
const percent1 = totalDuration > 0 ?
((watchedBeforeCurrent + state.video.currentTime) / totalDuration) * 100 : 0;
// 次进度:当前/当前分P
const currentPartDuration = state.partDurations[state.currentPart - 1] || currentDuration;
const percent2 = currentPartDuration > 0 ?
(state.video.currentTime / currentPartDuration) * 100 : 0;
const isComplete = percent1 >= 99.9;
return {
percent1,
percent2,
text: `累计: ${formatTime(watchedBeforeCurrent + state.video.currentTime)} / ${formatTime(totalDuration)}`,
current: formatTime(state.video.currentTime),
currentTotal: formatTime(currentPartDuration),
part: state.currentPart,
totalParts: state.totalParts,
isComplete
};
}
// 格式化时间
function formatTime(seconds) {
if (isNaN(seconds)) return "0:00";
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
// 更新UI
function updateUI() {
const progress = calculateDoubleProgress();
if (!progress) return;
if (progress.isComplete) {
state.canvas.style.filter = 'drop-shadow(0 0 5px #00a1d6)';
} else {
state.canvas.style.filter = 'none';
}
drawDoubleProgress(progress.percent1, progress.percent2);
if (state.tooltip) {
state.tooltip.innerHTML = `
<div><strong>总进度: ${progress.percent1.toFixed(1)}%</strong> (已看${progress.part}/${progress.totalParts}P)</div>
<div>${progress.text}</div>
<div style="margin-top:5px;border-top:1px solid #eee;padding-top:5px;">
<div>当前分P: ${progress.part}/${progress.totalParts}</div>
<div>当前进度: ${progress.current} / ${progress.currentTotal} (${progress.percent2.toFixed(1)}%)</div>
</div>
${progress.isComplete ? '<div style="color:#00a1d6;font-weight:bold;margin-top:5px;">✓ 已完成</div>' : ''}
`;
}
}
// 颜色选择器
function showColorPicker(e) {
e.preventDefault();
const popup = document.createElement('div');
popup.style.cssText = `
position: fixed;
left: ${e.clientX}px;
top: ${e.clientY}px;
background: white;
padding: 15px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: ${config.zIndex + 2};
display: grid;
grid-template-columns: 80px 1fr;
gap: 10px;
align-items: center;
`;
const title = document.createElement('div');
title.textContent = '设置';
title.style.cssText = 'grid-column: 1 / 3; font-weight: bold; margin-bottom: 5px;';
popup.appendChild(title);
// 颜色设置
addColorPicker(popup, '主进度颜色', 'progress', config.colors.progress);
addColorPicker(popup, '次进度颜色', 'progress2', config.colors.progress2);
addColorPicker(popup, '背景颜色', 'bg', config.colors.bg);
addColorPicker(popup, '文字颜色', 'text', config.colors.text);
// 透明度滑块
const opacityLabel = document.createElement('label');
opacityLabel.textContent = '透明度';
opacityLabel.style.textAlign = 'right';
popup.appendChild(opacityLabel);
const opacityContainer = document.createElement('div');
opacityContainer.style.display = 'flex';
opacityContainer.style.alignItems = 'center';
opacityContainer.style.gap = '10px';
const opacitySlider = document.createElement('input');
opacitySlider.type = 'range';
opacitySlider.min = '0.1';
opacitySlider.max = '1';
opacitySlider.step = '0.1';
opacitySlider.value = config.opacity;
opacitySlider.style.flex = '1';
const opacityValue = document.createElement('span');
opacityValue.textContent = Math.round(config.opacity * 100) + '%';
opacityValue.style.width = '40px';
opacitySlider.addEventListener('input', (e) => {
config.opacity = parseFloat(e.target.value);
opacityValue.textContent = Math.round(config.opacity * 100) + '%';
state.container.style.background = `rgba(246, 248, 250, ${config.opacity})`;
});
opacityContainer.appendChild(opacitySlider);
opacityContainer.appendChild(opacityValue);
popup.appendChild(opacityContainer);
const btnContainer = document.createElement('div');
btnContainer.style.cssText = 'grid-column: 1 / 3; display: flex; gap: 10px; margin-top: 5px;';
const resetBtn = document.createElement('button');
resetBtn.textContent = '重置默认';
resetBtn.addEventListener('click', () => {
config.colors = {
progress: '#FF9500',
progress2: '#00a1d6',
bg: '#eeeeee',
text: '#222'
};
config.opacity = 0.8;
opacitySlider.value = 0.8;
opacityValue.textContent = '80%';
GM_setValue('colors', config.colors);
GM_setValue('opacity', 0.8);
state.container.style.background = `rgba(246, 248, 250, 0.8)`;
updateUI();
});
const saveBtn = document.createElement('button');
saveBtn.textContent = '保存';
saveBtn.addEventListener('click', () => {
GM_setValue('colors', config.colors);
GM_setValue('opacity', config.opacity);
popup.remove();
});
const closeBtn = document.createElement('button');
closeBtn.textContent = '关闭';
closeBtn.addEventListener('click', () => popup.remove());
btnContainer.appendChild(resetBtn);
btnContainer.appendChild(saveBtn);
btnContainer.appendChild(closeBtn);
popup.appendChild(btnContainer);
function handleOutsideClick(e) {
if (!popup.contains(e.target)) {
popup.remove();
document.removeEventListener('click', handleOutsideClick);
}
}
setTimeout(() => {
document.addEventListener('click', handleOutsideClick);
}, 100);
document.body.appendChild(popup);
}
function addColorPicker(container, label, key, defaultValue) {
const labelEl = document.createElement('label');
labelEl.textContent = label;
labelEl.style.textAlign = 'right';
container.appendChild(labelEl);
const input = document.createElement('input');
input.type = 'color';
input.value = defaultValue;
input.addEventListener('input', (e) => {
config.colors[key] = e.target.value;
updateUI();
});
container.appendChild(input);
}
// 初始化
function init() {
createUI();
setupFullscreenListeners();
// 查找视频元素
state.video = document.querySelector('video');
if (!state.video) {
const observer = new MutationObserver(() => {
const video = document.querySelector('video');
if (video) {
state.video = video;
observer.disconnect();
state.video.addEventListener('timeupdate', updateUI);
fetchAllPartDurations();
updateUI();
}
});
observer.observe(document.body, { childList: true, subtree: true });
return;
}
state.video.addEventListener('timeupdate', updateUI);
fetchAllPartDurations();
updateUI();
}
// 启动
if (document.readyState === 'complete') {
setTimeout(init, 1000);
} else {
window.addEventListener('load', () => setTimeout(init, 1000));
}
})();