Bilibili CC字幕实时显示插件

在B站播放器中集成CC字幕列表

目前為 2024-12-01 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Bilibili CC字幕实时显示插件
// @name:en      Bilibili CC Subtitle Extractor
// @namespace    http://tampermonkey.net/
// @version      1.0.2
// @description  在B站播放器中集成CC字幕列表
// @description:en  Integrate CC subtitle list in Bilibili video player
// @author       Zane
// @match        *://*.bilibili.com/video/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // 字幕获取模块
    const SubtitleFetcher = {
        // 获取视频信息
        async getVideoInfo() {
            console.log('Getting video info...');
            
            const info = {
                aid: window.aid || window.__INITIAL_STATE__?.aid,
                bvid: window.bvid || window.__INITIAL_STATE__?.bvid,
                cid: window.cid
            };

            if (!info.cid) {
                const state = window.__INITIAL_STATE__;
                info.cid = state?.videoData?.cid || state?.epInfo?.cid;
            }

            if (!info.cid && window.player) {
                try {
                    const playerInfo = window.player.getVideoInfo();
                    info.cid = playerInfo.cid;
                    info.aid = playerInfo.aid;
                    info.bvid = playerInfo.bvid;
                } catch (e) {
                    console.log('Failed to get info from player:', e);
                }
            }

            console.log('Video info:', info);
            return info;
        },

        // 获取字幕配置
        async getSubtitleConfig(info) {
            console.log('Getting subtitle config...');
            
            const apis = [
                `//api.bilibili.com/x/player/v2?cid=${info.cid}&bvid=${info.bvid}`,
                `//api.bilibili.com/x/v2/dm/view?aid=${info.aid}&oid=${info.cid}&type=1`,
                `//api.bilibili.com/x/player/wbi/v2?cid=${info.cid}`
            ];

            for (const api of apis) {
                try {
                    console.log('Trying API:', api);
                    const res = await fetch(api);
                    const data = await res.json();
                    console.log('API response:', data);

                    if (data.code === 0 && data.data?.subtitle?.subtitles?.length > 0) {
                        return data.data.subtitle;
                    }
                } catch (e) {
                    console.log('API failed:', e);
                }
            }

            return null;
        },

        // 获取字幕内容
        async getSubtitleContent(subtitleUrl) {
            console.log('Getting subtitle content from:', subtitleUrl);
            
            try {
                const url = subtitleUrl.replace(/^http:/, 'https:');
                console.log('Using HTTPS URL:', url);
                
                const res = await fetch(url);
                const data = await res.json();
                console.log('Subtitle content:', data);
                return data;
            } catch (e) {
                console.error('Failed to get subtitle content:', e);
                return null;
            }
        }
    };

    // 时间格式化模块
    const TimeFormatter = {
        formatTime(seconds) {
            const mm = String(Math.floor(seconds/60)).padStart(2,'0');
            const ss = String(Math.floor(seconds%60)).padStart(2,'0');
            return `${mm}:${ss}`;
        },
        
        // 如果需要其他格式的时间显示,可以添加更多方法
        formatTimeWithMs(seconds) {
            const date = new Date(seconds * 1000);
            const mm = String(Math.floor(seconds/60)).padStart(2,'0');
            const ss = String(Math.floor(seconds%60)).padStart(2,'0');
            const ms = String(date.getMilliseconds()).slice(0,3).padStart(3,'0');
            return `${mm}:${ss},${ms}`;
        }
    };

    // UI渲染模块更新
    const SubtitleUI = {
        injectStyles() {
            const style = document.createElement('style');
            style.textContent = `
                .subtitle-container {
                    font-family: "PingFang SC", HarmonyOS_Regular, "Helvetica Neue", "Microsoft YaHei", sans-serif;
                    font-size: 12px;
                    -webkit-font-smoothing: antialiased;
                    color: rgb(24, 25, 28);
                    margin-top: 12px;
                }
    
                .subtitle-container * {
                    scrollbar-width: thin;
                    scrollbar-color: #99a2aa #fff;
                }

                .subtitle-container *::-webkit-scrollbar {
                    width: 4px;
                }

                .subtitle-container *::-webkit-scrollbar-track {
                    background: transparent;
                }

                .subtitle-container *::-webkit-scrollbar-thumb {
                    background-color: #99a2aa;
                    border-radius: 2px;
                }
    
                .subtitle-header {
                    display: flex;
                    align-items: center;
                    background-color: rgb(241, 242, 243);
                    height: 44px;
                    padding: 0 12px;
                    border-radius: 6px;
                    cursor: pointer;
                    user-select: none;
                    position: relative;
                }
    
                .subtitle-content {
                    background: var(--bg1, #fff);
                    height: 0;
                    overflow: hidden;
                    transition: all 0.3s;
                }
    
                .subtitle-function {
                    display: flex;
                    align-items: center;
                    height: 36px;
                    padding: 0 12px;
                    border-bottom: 1px solid var(--border, #e3e5e7);
                }
    
                .subtitle-function-btn {
                    display: flex;
                    align-items: center;
                    cursor: pointer;
                    color: #999;
                }

                .subtitle-function-btn:first-child {
                    width: 60px;
                }

                .subtitle-function-btn:last-child {
                    margin-left: 12px;
                }

                .subtitle-wrap {
                    height: 393px;
                    overflow-y: auto;
                    overscroll-behavior: contain;
                }
    
                .subtitle-item {
                    display: flex;
                    align-items: center;
                    padding: 0 12px;
                    height: 24px;
                    transition: background-color 0.3s;
                    cursor: pointer;
                }
    
                .subtitle-item:hover {
                    background: var(--bg2, #f1f2f3);
                }
    
                .subtitle-item.active {
                    background: var(--bg2, #f1f2f3);
                    color: var(--brand_blue, #00a1d6);
                }
    
                .subtitle-time {
                    width: 60px;
                    color: #999;
                    flex-shrink: 0;
                }
    
                .subtitle-text {
                    flex: 1;
                    white-space: nowrap;
                    overflow: hidden;
                    text-overflow: ellipsis;
                    margin: 0 12px;
                }
    
                .arrow-icon {
                    margin-right: 8px;
                    transition: transform 0.3s;
                }
    
                .arrow-icon.expanded {
                    transform: rotate(90deg);
                }
    
                .bui-collapse-wrap {
                    width: 350px;
                }
            `;
            document.head.appendChild(style);
        },
    
        createSubtitleUI() {
            const container = document.createElement('div');
            container.className = 'subtitle-container';
            
            // 头部
            const header = document.createElement('div');
            header.className = 'subtitle-header';
            header.innerHTML = `
                <div class="arrow-icon">
                    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
                        <path d="m9.188 7.999-3.359 3.359a.75.75 0 1 0 1.061 1.061l3.889-3.889a.75.75 0 0 0 0-1.061L6.89 3.58a.75.75 0 1 0-1.061 1.061l3.359 3.358z"/>
                    </svg>
                </div>
                <span>字幕列表</span>
            `;
    
            // 内容区
            const content = document.createElement('div');
            content.className = 'subtitle-content';
            
            const function_bar = document.createElement('div');
            function_bar.className = 'subtitle-function';
            function_bar.innerHTML = `
                <div class="subtitle-function-btn">
                    <span>时间</span>
                </div>
                <div class="subtitle-function-btn">
                    <span>字幕内容</span>
                </div>
            `;
            
            const wrap = document.createElement('div');
            wrap.className = 'subtitle-wrap';

            // 添加滚轮事件处理
            wrap.addEventListener('wheel', (e) => {
                // 阻止事件冒泡和默认行为
                e.stopPropagation();
                e.preventDefault();

                // 手动处理滚动
                wrap.scrollTop += e.deltaY;
            }, { passive: false });
            
            content.appendChild(function_bar);
            content.appendChild(wrap);
    
            container.appendChild(header);
            container.appendChild(content);
    
            return { container, header, content: wrap };
        }
    };

    // 字幕同步模块更新
    const SubtitleSync = {
        isVideoPlaying: true, // 视频播放状态
        lastManualScrollTime: 0, // 最后一次手动滚动时间
        
        displaySubtitles(subtitles, container) {
            const subtitleHtml = subtitles.body.map((item, index) => `
                <div class="subtitle-item" data-index="${index}">
                    <span class="subtitle-time">${TimeFormatter.formatTime(item.from)}</span>
                    <span class="subtitle-text">${item.content}</span>
                </div>
            `).join('');

            container.innerHTML = subtitleHtml;

            // 添加点击事件
            container.querySelectorAll('.subtitle-item').forEach(item => {
                item.addEventListener('click', () => {
                    const index = parseInt(item.dataset.index);
                    const subtitle = subtitles.body[index];
                    if (window.player && subtitle) {
                        window.player.seek(subtitle.from);
                    }
                });
            });

            // 添加滚动监听
            container.addEventListener('scroll', () => {
                this.lastManualScrollTime = Date.now();
            });

            // 监听视频播放状态
            if (window.player) {
                const observer = new MutationObserver(() => {
                    const video = document.querySelector('video');
                    if (video) {
                        this.isVideoPlaying = !video.paused;
                    }
                });

                observer.observe(document.querySelector('.bpx-player-container'), {
                    subtree: true,
                    attributes: true
                });
            }
        },

        // 计算元素在容器中的相对位置
        getRelativePosition(element, container) {
            const containerRect = container.getBoundingClientRect();
            const elementRect = element.getBoundingClientRect();
            
            return {
                top: elementRect.top - containerRect.top,
                bottom: elementRect.bottom - containerRect.top
            };
        },

        // 检查元素是否在容器的可视区域内
        isElementInViewport(element, container) {
            const pos = this.getRelativePosition(element, container);
            const containerHeight = container.clientHeight;
            
            // 考虑一定的缓冲区域
            const buffer = 50;
            return pos.top >= -buffer && pos.bottom <= containerHeight + buffer;
        },

        // 平滑滚动到指定元素
        smoothScrollToElement(element, container) {
            const pos = this.getRelativePosition(element, container);
            const containerHeight = container.clientHeight;
            const targetScroll = container.scrollTop + pos.top - containerHeight / 2;
            
            container.scrollTo({
                top: targetScroll,
                behavior: 'smooth'
            });
        },

        highlightCurrentSubtitle(subtitles, container) {
            const currentTime = window.player?.getCurrentTime() || 0;
            
            container.querySelectorAll('.subtitle-item').forEach(item => {
                item.classList.remove('active');
            });

            const currentSubtitle = subtitles.body.find(item => 
                currentTime >= item.from && currentTime <= item.to
            );

            if (currentSubtitle) {
                const index = subtitles.body.indexOf(currentSubtitle);
                const currentElement = container.querySelector(`.subtitle-item[data-index="${index}"]`);
                
                if (currentElement) {
                    currentElement.classList.add('active');
                    
                    // 只在视频播放时且距离上次手动滚动超过2秒时自动滚动
                    if (this.isVideoPlaying && Date.now() - this.lastManualScrollTime > 2000) {
                        // 检查当前字幕是否在可视区域内
                        if (!this.isElementInViewport(currentElement, container)) {
                            this.smoothScrollToElement(currentElement, container);
                        }
                    }
                }
            }
        }
    };

    // 主函数更新
    async function main() {
        // 等待弹幕列表容器加载
        const danmakuContainer = await new Promise(resolve => {
            const check = () => {
                const container = document.querySelector('.bui-collapse-wrap');
                if (container) {
                    resolve(container);
                } else {
                    setTimeout(check, 1000);
                }
            };
            check();
        });

        // 注入样式
        SubtitleUI.injectStyles();

        // 创建UI
        const { container, header, content } = SubtitleUI.createSubtitleUI();
        danmakuContainer.appendChild(container);

        // 切换展开/收起
        let isExpanded = false;
        header.addEventListener('click', () => {
            isExpanded = !isExpanded;
            container.querySelector('.subtitle-content').style.height = 
                isExpanded ? '429px' : '0';  // 36px(功能栏) + 393px(内容区)
            header.querySelector('.arrow-icon').classList.toggle('expanded', isExpanded);
        });
        // 加载字幕
        try {
            const videoInfo = await SubtitleFetcher.getVideoInfo();
            if (!videoInfo.cid) {
                throw new Error('无法获取视频信息');
            }

            const subtitleConfig = await SubtitleFetcher.getSubtitleConfig(videoInfo);
            if (!subtitleConfig) {
                throw new Error('该视频没有CC字幕');
            }

            const subtitles = await SubtitleFetcher.getSubtitleContent(subtitleConfig.subtitles[0].subtitle_url);
            if (!subtitles) {
                throw new Error('获取字幕内容失败');
            }

            // 显示字幕
            SubtitleSync.displaySubtitles(subtitles, content);

            // 启动字幕同步
            setInterval(() => {
                if (isExpanded) {
                    SubtitleSync.highlightCurrentSubtitle(subtitles, content);
                }
            }, 100);

        } catch (error) {
            console.error('Error:', error);
            content.innerHTML = `<div class="subtitle-item">${error.message}</div>`;
        }
    }

    // 等待页面加载完成后执行
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', main);
    } else {
        main();
    }
})();