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