Greasy Fork

Greasy Fork is available in English.

小叶的b站视频时间查询器

查询b站视频总时长并计算倍速下的观看时间,支持窗口拖动与动态调整显示样式!

当前为 2024-10-22 提交的版本,查看 最新版本

// ==UserScript==
// @name 小叶的b站视频时间查询器
// @namespace http://tampermonkey.net/
// @version 1.0.4
// @description 查询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;
        left: 0;
        top: 50%;
        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; left: 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();
})();