// ==UserScript== // @name 小叶的b站学习助手(视频时间查询器+视频倍速播放) // @namespace http://tampermonkey.net/ // @version 2.1.2 // @description 这一款专为B站用户打造的实用小工具,包含了B站视频的倍速播放(支持快捷键和控制面板设置),以及视频时间查询器(够便捷地计算视频的总时长,并根据不同的倍速计算实际的观看时间)。这款工具除了提供精确的时间统计,还具备窗口拖动、动态样式调整等功能,非常适合在B站学习课程的用户使用;2.x版本将“倍速功能”单独拆分到独立UI和单独热键监听中,并且原来的“时间计算”功能保持不变。 // @author 小叶 // @license AGPL License // @match *://*.bilibili.com/video/* // @icon https://www.bilibili.com/favicon.ico // @grant GM_setValue // @grant GM_getValue // @downloadURL none // ==/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' }, // 倍速配置 SPEED_UI: { SHOW_SPEED_UI: true // 设置是否显示倍速UI,默认为true }, // 样式配置 STYLE: { COLORS: { // 主色调(B站蓝) PRIMARY: '#00A1D6', // 次要色调 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(CONFIG.UI.TRIGGER_ID); const textContainer = triggerContainer.querySelector('div'); // 获取文本容器 if (isPopupVisible) { createTimeCalcUI(); triggerContainer.style.width = '80px'; textContainer.style.display = 'block'; textContainer.style.color = '#FF0000'; textContainer.innerText = '关闭计时器'; } else { closeTimeCalcUI(); 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'; }; } }; //创建计时器UI const createTimeCalcUI = () => { const existingDiv = document.getElementById(CONFIG.UI.CONTAINER_ID); if (existingDiv) { existingDiv.remove(); } const body = document.body; const container = document.createElement("div"); container.id = CONFIG.UI.CONTAINER_ID; 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 = CONFIG.TEXT.TITLE; 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 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 = CONFIG.FEATURES.TIME_FORMATS; // ["时分秒", "仅小时", "仅分钟", "仅秒"] 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(CONFIG.UI.TRIGGER_ID); 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, input1.value, input2.value); }; container.appendChild(btn); const resultDiv = document.createElement("div"); resultDiv.id = CONFIG.UI.RESULT_DIV_ID; resultDiv.style.cssText = ` margin-top: 15px; color: #333; font-weight: bold; text-align: center; `; container.appendChild(resultDiv); const footer = document.createElement("div"); footer.innerText = CONFIG.TEXT.FOOTER; footer.style.cssText = ` margin-top: 20px; color: #888; font-size: 12px; text-align: center; `; container.appendChild(footer); body.appendChild(container); }; //关闭UI const closeTimeCalcUI = () => { const existingDiv = document.getElementById(CONFIG.UI.CONTAINER_ID); if (existingDiv) { existingDiv.remove(); } }; // 计算时间函数 (已经移除倍速相关逻辑) const calculateTime = (format, startStr, endStr) => { // 获取所有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(startStr, 10); const input2Value = parseInt(endStr, 10); // 输入验证 if (isNaN(input1Value) || isNaN(input2Value)) { updateResult(CONFIG.TEXT.MESSAGES.INVALID_INPUT); return; } // 验证最小集数 if (input1Value < CONFIG.FEATURES.MIN_EPISODE) { updateResult(CONFIG.TEXT.MESSAGES.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); 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; } // 转换时间格式 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(CONFIG.UI.RESULT_DIV_ID); if (!resultDiv) return; 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) => { if (e.target.tagName.toLowerCase() === 'input' || e.target.tagName.toLowerCase() === 'textarea') { return; } 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"; }); }; // 初始化时间查询触发器 createPopupTrigger(); // ===============【 二、全新“倍速调节”UI与按键监听 】================ /** * 存储本脚本内部的倍速值 * 如需记忆上次倍速,可以使用 GM_getValue/GM_setValue 来持久化 */ let currentSpeed = 1.0; let originalSpeed = 1.0; // 记录原始倍速 /** * 将页面上所有