B站循环助手-稳定版

稳定可靠的AB点循环工具,适配最新B站页面结构

目前為 2025-02-21 提交的版本,檢視 最新版本

// ==UserScript==
// @name         B站循环助手-稳定版
// @namespace    bilibili-replayer
// @version      1.5
// @description  稳定可靠的AB点循环工具,适配最新B站页面结构
// @author       dms
// @match        https://www.bilibili.com/video/BV*
// @match        https://www.bilibili.com/bangumi/play/ep*
// @match        https://www.bilibili.com/medialist/play/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=bilibili.com
// @grant        GM_notification
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==

(function() {
    'use strict';
    
    // 存储管理
    const Storage = {
        savePoint: (index, value) => {
            try {
                GM_setValue(`Point_${index}`, value);
                return true;
            } catch(e) {
                console.error('保存点位失败:', e);
                return false;
            }
        },
        getPoint: (index) => {
            try {
                return GM_getValue(`Point_${index}`, null);
            } catch(e) {
                console.error('获取点位失败:', e);
                return null;
            }
        }
    };

    // 工具函数
    const Utils = {
        isNormalVideo: /^(https?:\/\/(www\.)bilibili\.com\/video\/(?:BV|AV)\w+).*/i.test(window.location.href),
        
        async copyText(text) {
            try {
                if (navigator.clipboard && window.isSecureContext) {
                    await navigator.clipboard.writeText(text);
                    return true;
                }
            } catch(e) {
                console.warn('现代复制API失败,使用传统方法');
            }
            
            try {
                const textArea = document.createElement('textarea');
                textArea.value = text;
                textArea.style.position = 'fixed';
                textArea.style.left = '-9999px';
                document.body.appendChild(textArea);
                textArea.select();
                document.execCommand('copy');
                textArea.remove();
                return true;
            } catch(e) {
                console.error('复制失败:', e);
                return false;
            }
        },

        createButton(text, className, parent) {
            const button = document.createElement('div');
            className.split(' ').forEach(c => button.classList.add(c));
            button.innerText = text;
            parent.appendChild(button);
            return button;
        },

        showNotification(text, title = '提示', timeout = 2000) {
            GM_notification({
                text,
                title,
                timeout
            });
        }
    };

    // UI创建和管理
    const UI = {
        createStyle: () => `
            #replayer-toolbar {
                width: 100%;
                height: 1.2rem;
                font-size: 0.7rem;
                line-height: 1.2rem;
                margin: 0;
                padding: 0;
                position: relative;
                z-index: 100;
            }
            #replayer-toolbar::after {
                content: " ";
                display: block;
                clear: both;
            }
            .tool-item {
                float: left;
                padding: 0 0.25rem;
                border-radius: .2rem;
            }
            .tool-button {
                border: 1px solid rgba(0, 0, 0, .1);
                cursor: pointer;
                transition: all 0.2s ease;
            }
            .tool-button:hover {
                background-color: rgba(0, 174, 236, 0.1);
            }
            .active-button {
                background-color: #00aeec;
                color: white;
            }
            .hide {
                display: none;
            }
        `
    };

    class VideoController {
        constructor(video) {
            this.video = video;
            this.points = [0, video.duration-1];
            this.pointButtons = [];
            this.animationFrameId = null;
            this.lastTime = 0;
        }

        setPoint(index, value) {
            if(value && value.trim()) {
                if(!/^(\d+h)?(\d+m)?\d+(\.\d+)?$/i.test(value)) {
                    Utils.showNotification('时间格式有误,请检查输入格式', '输入错误');
                    return;
                }
                
                if(typeof(value) === 'string') {
                    const hms = value.split(/h|m/g);
                    const h = /\d+h/.test(value) ? +hms[0] : 0;
                    const m = /\d+m/.test(value) ? +hms[hms.length-2] : 0;
                    const s = +hms[hms.length-1];
                    
                    this.points[index] = h*3600 + m*60 + s;
                } else {
                    this.points[index] = value;
                }
                
                this.pointButtons[index].classList.add('active-button');
                Storage.savePoint(index, this.points[index]);
                return;
            }
            
            this.points[index] = index ? this.video.duration-1 : 0;
            this.pointButtons[index].classList.remove('active-button');
            Storage.savePoint(index, null);
        }

        startLoop(button) {
            if(this.animationFrameId) {
                cancelAnimationFrame(this.animationFrameId);
                this.animationFrameId = null;
                button.classList.remove('active-button');
                return;
            }
            
            button.classList.add('active-button');
            const checkLoop = (timestamp) => {
                if (timestamp - this.lastTime > 200) {
                    const A = this.points[0] <= this.points[1] ? this.points[0] : this.points[1];
                    const B = this.points[0] > this.points[1] ? this.points[0] : this.points[1];
                    if(this.video.currentTime >= B) {
                        this.video.currentTime = A;
                    }
                    this.lastTime = timestamp;
                }
                this.animationFrameId = requestAnimationFrame(checkLoop);
            };
            this.animationFrameId = requestAnimationFrame(checkLoop);
        }

        async getTimeLink(time) {
            const link = window.location.href.replace(/^(https?:\/\/(www\.)bilibili\.com\/video\/(?:BV|AV)\w+).*/i, '$1') + '?t=' + time;
            const success = await Utils.copyText(link);
            Utils.showNotification(
                success ? '时间标记链接已复制到剪切板' : '复制失败,请手动复制',
                success ? '复制成功' : '复制失败'
            );
        }
    }

    const createToolbar = async () => {
        const video = document.querySelector('#bilibili-player video');
        if (!video) return;

        const controller = new VideoController(video);
        
        const toolbarbox = document.createElement('div');
        toolbarbox.style = 'width: 100%; height: 1.2rem; position: relative; z-index: 100;';
        
        const container = document.querySelector('#playerWrap') || document.querySelector('#player_module');
        container.appendChild(toolbarbox);

        const toolbarShadow = toolbarbox.attachShadow({mode: 'closed'});
        const toolbarStyle = document.createElement('style');
        toolbarStyle.innerHTML = UI.createStyle();
        toolbarShadow.appendChild(toolbarStyle);

        const toolbar = document.createElement('div');
        toolbar.id = 'replayer-toolbar';
        toolbarShadow.appendChild(toolbar);

        const getLinkClass = Utils.isNormalVideo ? '' : ' hide';

        Utils.createButton('起点:', 'tool-item tool-text', toolbar);
        const pointA = Utils.createButton('起点A', 'tool-item tool-button', toolbar);
        const toA = Utils.createButton('跳到这里', 'tool-item tool-button', toolbar);
        const linkA = Utils.createButton('复制链接', 'tool-item tool-button' + getLinkClass, toolbar);
        
        Utils.createButton('|', 'tool-item tool-text', toolbar);
        Utils.createButton('终点:', 'tool-item tool-text', toolbar);
        const pointB = Utils.createButton('终点B', 'tool-item tool-button', toolbar);
        const toB = Utils.createButton('跳到这里', 'tool-item tool-button', toolbar);
        const linkB = Utils.createButton('复制链接', 'tool-item tool-button' + getLinkClass, toolbar);
        
        Utils.createButton('|', 'tool-item tool-text', toolbar);
        const Start = Utils.createButton('开始循环', 'tool-item tool-button', toolbar);
        Utils.createButton('|', 'tool-item tool-text' + getLinkClass, toolbar);
        Utils.createButton('当前:', 'tool-item tool-text' + getLinkClass, toolbar);
        const linkNow = Utils.createButton('复制链接', 'tool-item tool-button' + getLinkClass, toolbar);

        controller.pointButtons = [pointA, pointB];

        // 加载保存的点位
        const savedPoints = [Storage.getPoint(0), Storage.getPoint(1)];
        savedPoints.forEach((point, index) => {
            if(point !== null) {
                controller.points[index] = point;
                controller.pointButtons[index].classList.add('active-button');
            }
        });

        // 绑定事件
        pointA.addEventListener('click', () => {
            const input = prompt('请输入时间点(示例:1h2m30 或 2m30 或 30)\n默认使用当前时间,取消则重置为视频开头', video.currentTime);
            controller.setPoint(0, input);
        });

        pointB.addEventListener('click', () => {
            const input = prompt('请输入时间点(示例:1h2m30 或 2m30 或 30)\n默认使用当前时间,取消则重置为视频结尾', video.currentTime);
            controller.setPoint(1, input);
        });

        Start.addEventListener('click', () => controller.startLoop(Start));
        toA.addEventListener('click', () => { video.currentTime = controller.points[0]; });
        toB.addEventListener('click', () => { video.currentTime = controller.points[1]; });
        linkA.addEventListener('click', () => controller.getTimeLink(controller.points[0]));
        linkB.addEventListener('click', () => controller.getTimeLink(controller.points[1]));
        linkNow.addEventListener('click', () => controller.getTimeLink(video.currentTime));
    };

    // 等待视频元素加载
    const waitForVideo = () => {
        let attempts = 0;
        const check = setInterval(() => {
            if(document.querySelector('#bilibili-player video')) {
                clearInterval(check);
                createToolbar();
            }
            attempts++;
            if(attempts > 30) {
                clearInterval(check);
                console.error('未找到视频元素');
            }
        }, 1000);
    };

    if (document.readyState === 'complete') {
        waitForVideo();
    } else {
        window.addEventListener('load', waitForVideo);
    }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址