Greasy Fork is available in English.
这一款专为B站用户打造的实用小工具,能够便捷地计算视频的总时长,并根据不同的倍速计算实际的观看时间。除了提供精确的时间统计,这款工具还具备窗口拖动、动态样式调整等功能,非常适合在B站学习课程的用户使用。
当前为
// ==UserScript==
// @name 小叶的b站视频时间查询器
// @namespace http://tampermonkey.net/
// @version 1.0.4.2
// @description 这一款专为B站用户打造的实用小工具,能够便捷地计算视频的总时长,并根据不同的倍速计算实际的观看时间。除了提供精确的时间统计,这款工具还具备窗口拖动、动态样式调整等功能,非常适合在B站学习课程的用户使用。
// @author 小叶
// @license AGPL License
// @match *://*.bilibili.com/video/*
// @icon https://www.bilibili.com/favicon.ico
// @grant GM_setValue
// @grant GM_getValue
// ==/UserScript==
(function() {
'use strict';
// 全局配置对象
const CONFIG = {
// UI 配置
UI: {
TRIGGER_ID: 'popup-trigger-container',
CONTAINER_ID: 'time-calculator-container',
RESULT_DIV_ID: 'resultDiv',
DEFAULT_OPACITY: 0.8,
Z_INDEX: 999999,
ICON_URL: 'https://www.bilibili.com/favicon.ico' // Added ICON_URL
},
// 样式配置
STYLE: {
COLORS: {
PRIMARY: '#00A1D6', // 主色调(B站蓝)
SECONDARY: '#40E0D0', // 次要色调
WARNING: '#FF6347', // 警告色
HOVER: '#008BB5', // 悬停色
TEXT: {
PRIMARY: '#333', // 主要文本色
SECONDARY: '#888' // 次要文本色
}
},
BORDER_RADIUS: {
SMALL: '4px',
MEDIUM: '8px',
LARGE: '16px'
},
TRANSITIONS: {
DEFAULT: 'all 0.3s ease'
}
},
// 功能配置
FEATURES: {
RESULT_DISPLAY_TIME: 15000, // 结果显示时间(毫秒)
MIN_EPISODE: 1, // 最小集数
MIN_SPEED: 0.5, // 最小倍速
SPEED_STEP: 0.1, // 倍速调整步长
DEFAULT_SPEED: 1, // 默认倍速
TIME_FORMATS: ["时分秒", "仅小时", "仅分钟", "仅秒"]
},
// 布局配置
LAYOUT: {
SNAP_PADDING: 20,
CONTAINER_WIDTH: '280px',
TRIGGER_WIDTH: {
DEFAULT: '40px',
EXPANDED: '80px'
}
},
// 文本配置
TEXT: {
TRIGGER_TEXT: "小叶计时器",
CLOSE_TEXT: "关闭计时器",
TITLE: "小叶的B站时间查询器",
FOOTER: "小叶计时器",
MESSAGES: {
INVALID_INPUT: "请输入有效的数值。",
MIN_EPISODE: "最小为第1集",
INVALID_RANGE: "输入的集数范围不正确。",
NO_DURATION: "无法获取视频时长,请确保已加载视频列表。",
MAX_EPISODE: "最大为第{count}集"
}
},
// 元素类名配置
CLASSES: {
DURATION: 'duration',
STATS: 'stats'
}
};
// 读取存储的透明度或使用默认值
let containerOpacity = GM_getValue('containerOpacity', CONFIG.UI.DEFAULT_OPACITY);
let isPopupVisible = false;
// 添加一个计时器ID变量用于跟踪
let resultTimeoutId = null;
// 创建触发器
const createPopupTrigger = () => {
// 检查页面上是否存在指定的类名
if (!document.querySelector(`.${CONFIG.CLASSES.STATS}`)) {
console.log('没有找到视频元素,触发器不会显示。');
return; // 如果没有找到,直接返回不创建触发器
}
// 删除现有的触发器(如果存在)
const existingTrigger = document.getElementById(CONFIG.UI.TRIGGER_ID);
if (existingTrigger) {
existingTrigger.remove();
}
const body = document.body;
const triggerContainer = document.createElement("div");
triggerContainer.id = CONFIG.UI.TRIGGER_ID;
// 修改了容器的样式
triggerContainer.style.cssText = `
position: fixed;
right: 0;
top: 12%;
transform: translateY(-50%);
z-index: ${CONFIG.UI.Z_INDEX};
text-align: center;
border: 1px solid ${CONFIG.STYLE.COLORS.PRIMARY};
border-radius: ${CONFIG.STYLE.BORDER_RADIUS.MEDIUM};
background-color: rgba(255, 255, 255, ${containerOpacity});
padding: 8px;
width: ${CONFIG.LAYOUT.TRIGGER_WIDTH.DEFAULT};
transition: ${CONFIG.STYLE.TRANSITIONS.DEFAULT};
cursor: pointer;
margin-left: 5px;
`;
// 创建并设置图标
const icon = document.createElement("img");
icon.src = CONFIG.UI.ICON_URL;
icon.alt = "B站图标";
icon.style.cssText = `
width: 24px;
height: 24px;
display: block;
margin: 0 auto;
transition: ${CONFIG.STYLE.TRANSITIONS.DEFAULT};
`;
// 创建文本容器
const textContainer = document.createElement("div");
textContainer.style.cssText = `
font-size: 12px;
color: ${CONFIG.STYLE.COLORS.PRIMARY};
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
display: none;
`;
textContainer.innerText = CONFIG.TEXT.TRIGGER_TEXT;
// 添加hover效果
triggerContainer.onmouseenter = () => {
triggerContainer.style.width = CONFIG.LAYOUT.TRIGGER_WIDTH.EXPANDED;
textContainer.style.display = 'block';
};
triggerContainer.onmouseleave = () => {
if (!isPopupVisible) {
triggerContainer.style.width = CONFIG.LAYOUT.TRIGGER_WIDTH.DEFAULT;
textContainer.style.display = 'none';
}
};
// 添加点击事件
triggerContainer.onclick = togglePopup;
// 组装触发器
triggerContainer.appendChild(icon);
triggerContainer.appendChild(textContainer);
body.appendChild(triggerContainer);
return triggerContainer;
};
const togglePopup = () => {
isPopupVisible = !isPopupVisible;
const triggerContainer = document.getElementById('popup-trigger-container');
const textContainer = triggerContainer.querySelector('div'); // 获取文本容器
if (isPopupVisible) {
createUI();
triggerContainer.style.width = '80px';
textContainer.style.display = 'block';
textContainer.style.color = '#FF0000';
textContainer.innerText = '关闭计时器';
} else {
closeUI();
triggerContainer.style.width = '40px';
textContainer.style.color = '#00A1D6';
textContainer.innerText = '小叶计时器';
// 恢复hover效果
triggerContainer.onmouseenter = () => {
triggerContainer.style.width = '80px';
textContainer.style.display = 'block';
};
triggerContainer.onmouseleave = () => {
triggerContainer.style.width = '40px';
textContainer.style.display = 'none';
};
}
};
const createUI = () => {
const existingDiv = document.getElementById('time-calculator-container');
if (existingDiv) {
existingDiv.remove();
}
const body = document.body;
const container = document.createElement("div");
container.id = "time-calculator-container";
container.style.cssText = `padding: 20px; background-color: rgba(255, 255, 255, ${containerOpacity}); position: fixed; right: 20px; top: 20%; width: 280px; max-width: 90%; border-radius: 16px; box-shadow: 0 8px 16px rgba(0,0,0,0.2); border: 1px solid #40E0D0; z-index: 999; text-align: center; font-size: 14px; color: #333;`;
makeElementDraggable(container);
const closeButton = document.createElement("button");
closeButton.innerText = "关闭";
closeButton.style.cssText = "position: absolute; top: 5px; right: 5px; border: none; background-color: #FF6347; color: #FFF; padding: 5px 10px; cursor: pointer; border-radius: 4px;";
closeButton.onclick = togglePopup;
container.appendChild(closeButton);
const title = document.createElement("h4");
title.innerText = "小叶的B站时间查询器";
title.style.cssText = "margin-bottom: 20px; color: #00A1D6; font-weight: bold; text-align: center;";
container.appendChild(title);
const inputDiv = document.createElement("div");
inputDiv.style.cssText = "margin-bottom: 15px; display: flex; justify-content: center; align-items: center;";
const label1 = document.createElement("label");
label1.innerText = "从第";
label1.style.cssText = "margin-right: 5px;";
inputDiv.appendChild(label1);
const input1 = document.createElement('input');
input1.type = "number";
input1.style.cssText = "border: 1px solid deepskyblue; width: 50px; text-align: center; margin-right: 5px; padding: 5px; border-radius: 4px;";
input1.min = 1;
inputDiv.appendChild(input1);
const label2 = document.createElement("label");
label2.innerText = "集 到";
label2.style.cssText = "margin-right: 5px;";
inputDiv.appendChild(label2);
const input2 = document.createElement('input');
input2.type = "number";
input2.style.cssText = "border: 1px solid deepskyblue; width: 50px; text-align: center; padding: 5px; border-radius: 4px;";
input2.min = 1;
inputDiv.appendChild(input2);
container.appendChild(inputDiv);
const speedDiv = document.createElement("div");
speedDiv.style.cssText = "margin-bottom: 15px; display: flex; justify-content: center; align-items: center;";
const label3 = document.createElement("label");
label3.innerText = "倍速:";
label3.style.cssText = "margin-right: 5px;";
speedDiv.appendChild(label3);
const input3 = document.createElement('input');
input3.type = "number";
input3.style.cssText = "border: 1px solid deepskyblue; width: 60px; text-align: center; padding: 5px; border-radius: 4px; margin-right: 5px;";
input3.value = 1;
input3.min = 0.5;
input3.step = 0.1;
speedDiv.appendChild(input3);
const label4 = document.createElement("label");
label4.innerText = " 倍";
speedDiv.appendChild(label4);
container.appendChild(speedDiv);
const formatDiv = document.createElement("div");
formatDiv.style.cssText = "margin-bottom: 20px; display: flex; justify-content: center; align-items: center;";
const formatLabel = document.createElement("label");
formatLabel.innerText = "显示格式:";
formatLabel.style.cssText = "margin-right: 5px;";
formatDiv.appendChild(formatLabel);
const formatSelect = document.createElement('select');
formatSelect.style.cssText = "padding: 5px; border-radius: 4px; border: 1px solid deepskyblue;";
const options = ["时分秒", "仅小时", "仅分钟", "仅秒"];
options.forEach(optionText => {
const option = document.createElement('option');
option.value = optionText;
option.innerText = optionText;
formatSelect.appendChild(option);
});
formatDiv.appendChild(formatSelect);
container.appendChild(formatDiv);
const transparencyDiv = document.createElement("div");
transparencyDiv.style.cssText = "margin-bottom: 20px; text-align: center;";
const transparencyLabel = document.createElement("label");
transparencyLabel.innerText = "调整透明度:";
transparencyDiv.appendChild(transparencyLabel);
const transparencySlider = document.createElement('input');
transparencySlider.type = "range";
transparencySlider.min = 0.1;
transparencySlider.max = 1;
transparencySlider.step = 0.1;
transparencySlider.value = containerOpacity;
transparencySlider.style.cssText = "margin-left: 10px;";
transparencySlider.oninput = (e) => {
containerOpacity = e.target.value;
container.style.backgroundColor = `rgba(255, 255, 255, ${containerOpacity})`;
const triggerContainer = document.getElementById('popup-trigger-container');
if (triggerContainer) {
triggerContainer.style.backgroundColor = `rgba(255, 255, 255, ${containerOpacity})`;
}
GM_setValue('containerOpacity', containerOpacity);
};
transparencyDiv.appendChild(transparencySlider);
container.appendChild(transparencyDiv);
const btn = document.createElement('button');
btn.innerText = "计算时间";
btn.style.cssText = "width: 100%; padding: 12px; border: none; background-color: #00A1D6; color: #FFFFFF; cursor: pointer; border-radius: 8px; font-size: 16px; margin-bottom: 20px;";
btn.onmouseover = () => { btn.style.backgroundColor = "#008BB5"; };
btn.onmouseout = () => { btn.style.backgroundColor = "#00A1D6"; };
btn.onclick = () => calculateTime(formatSelect.value);
container.appendChild(btn);
const resultDiv = document.createElement("div");
resultDiv.id = "resultDiv";
resultDiv.style.cssText = "margin-top: 15px; color: #333; font-weight: bold; text-align: center;";
container.appendChild(resultDiv);
const footer = document.createElement("div");
footer.innerText = "小叶计时器";
footer.style.cssText = "margin-top: 20px; color: #888; font-size: 12px; text-align: center;";
container.appendChild(footer);
body.appendChild(container);
};
const closeUI = () => {
const existingDiv = document.getElementById('time-calculator-container');
if (existingDiv) {
existingDiv.remove();
}
};
// 计算时间函数
const calculateTime = (format) => {
// 获取所有duration元素
const allDurations = document.getElementsByClassName(CONFIG.CLASSES.DURATION);
// 只筛选父元素className包含stats的元素
const durations = Array.from(allDurations).filter(el =>
el.parentElement.className.includes(CONFIG.CLASSES.STATS)
);
const input1Value = parseInt(document.querySelectorAll('input[type=number]')[0].value, 10);
const input2Value = parseInt(document.querySelectorAll('input[type=number]')[1].value, 10);
const speedValue = parseFloat(document.querySelectorAll('input[type=number]')[2].value);
// 输入验证
if (isNaN(input1Value) || isNaN(input2Value) || isNaN(speedValue)) {
updateResult(CONFIG.TEXT.MESSAGES.INVALID_INPUT);
return;
}
// 验证最小集数
if (input1Value < CONFIG.FEATURES.MIN_EPISODE) {
updateResult(CONFIG.TEXT.MESSAGES.MIN_EPISODE);
document.querySelectorAll('input[type=number]')[0].value = CONFIG.FEATURES.MIN_EPISODE;
return;
}
// 验证集数范围
if (input2Value < input1Value) {
updateResult(CONFIG.TEXT.MESSAGES.INVALID_RANGE);
return;
}
// 验证是否获取到视频时长
if (durations.length === 0) {
updateResult(CONFIG.TEXT.MESSAGES.NO_DURATION);
return;
}
// 验证最大集数
if (input2Value > durations.length) {
const message = CONFIG.TEXT.MESSAGES.MAX_EPISODE.replace('{count}', durations.length);
updateResult(message);
document.querySelectorAll('input[type=number]')[1].value = durations.length;
return;
}
// 计算总时长
let totalSeconds = 0;
for (let i = input1Value - 1; i < input2Value; i++) {
const duration = durations[i].innerText;
const timeParts = duration.split(':').map(Number);
let seconds = timeParts.pop();
let minutes = timeParts.pop() || 0;
let hours = timeParts.pop() || 0;
totalSeconds += hours * 3600 + minutes * 60 + seconds;
}
// 应用倍速
totalSeconds /= speedValue;
// 转换时间格式
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
// 根据选择的格式生成结果文本
let resultText;
switch (format) {
case "时分秒":
resultText = `总时长:${hours}时${minutes}分${seconds}秒`;
break;
case "仅小时":
resultText = `总时长:${(totalSeconds / 3600).toFixed(2)} 小时`;
break;
case "仅分钟":
resultText = `总时长:${(totalSeconds / 60).toFixed(2)} 分钟`;
break;
case "仅秒":
resultText = `总时长:${Math.round(totalSeconds)} 秒`;
break;
}
// 显示结果
updateResult(resultText);
};
// 更新结果显示
const updateResult = (text) => {
const resultDiv = document.getElementById('resultDiv');
resultDiv.innerText = text;
// 如果已经存在计时器,先清除它
if (resultTimeoutId) {
clearTimeout(resultTimeoutId);
}
// 设置新的计时器并保存ID
resultTimeoutId = setTimeout(() => {
if (resultDiv) { // 检查元素是否还存在
resultDiv.innerText = '';
}
resultTimeoutId = null;
}, CONFIG.FEATURES.RESULT_DISPLAY_TIME);
};
const makeElementDraggable = (element) => {
let offsetX = 0, offsetY = 0, isDragging = false;
element.addEventListener('mousedown', (e) => {
isDragging = true;
offsetX = e.clientX - element.getBoundingClientRect().left;
offsetY = e.clientY - element.getBoundingClientRect().top;
element.style.transition = "none";
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
element.style.left = `${e.clientX - offsetX}px`;
element.style.top = `${e.clientY - offsetY}px`;
});
document.addEventListener('mouseup', () => {
isDragging = false;
element.style.transition = "all 0.3s ease";
});
};
const makeElementDraggableWithSnap = (element) => {
let offsetX = 0, offsetY = 0, isDragging = false;
element.addEventListener('mousedown', (e) => {
isDragging = true;
offsetX = e.clientX - element.getBoundingClientRect().left;
offsetY = e.clientY - element.getBoundingClientRect().top;
element.style.transition = "none";
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
element.style.left = `${e.clientX - offsetX}px`;
element.style.top = `${e.clientY - offsetY}px`;
});
document.addEventListener('mouseup', () => {
isDragging = false;
element.style.transition = "all 0.3s ease";
snapToEdge(element);
});
};
const snapToEdge = (element) => {
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const elementRect = element.getBoundingClientRect();
const snapPadding = 20;
const snapToLeft = elementRect.left < snapPadding;
const snapToRight = windowWidth - elementRect.right < snapPadding;
const snapToTop = elementRect.top < snapPadding;
const snapToBottom = windowHeight - elementRect.bottom < snapPadding;
if (snapToLeft) {
element.style.left = "0px";
} else if (snapToRight) {
element.style.left = `${windowWidth - elementRect.width}px`;
}
if (snapToTop) {
element.style.top = "0px";
} else if (snapToBottom) {
element.style.top = `${windowHeight - elementRect.height}px`;
}
};
createPopupTrigger();
})();