Bilibili 自动开启字幕

辣鸡 B 站到 2025 年还不支持自动开启字幕

// ==UserScript==
// @name         Bilibili 自动开启字幕
// @namespace    http://tampermonkey.net/
// @description  辣鸡 B 站到 2025 年还不支持自动开启字幕
// @version      1.4.1
// @author       Yuna (with modifications)
// @match        *://www.bilibili.com/video/*
// @grant        GM_registerMenuCommand
// @run-at       document-end
// @license      MIT
// ==/UserScript==

( function ()
{
    'use strict';


    const CONFIG = {
        maxRetryCount: 3, retryIntervalMs: 1000, closeNotificationTimeoutMs: 3000, debug: true,
    };

    const selectorList = {
        playerContainer: ".bpx-player-video-wrap",
        videoPlayer: ".bpx-player-video-wrap video",
        notificationBox: ".bpx-player-toast-auto",
        notificationItem: ".bpx-player-toast-row.bpx-player-toast-unfold",
        languageButton: ".bpx-player-ctrl-subtitle-language-item",
        subtitleItem: ".bpx-player-ctrl-subtitle-language-item",
    };

    const classNameList = {
        isActive: "bpx-state-active",
        subtitleList: "bpx-player-ctrl-subtitle-menu-left",
        subtitleItem: "bpx-player-ctrl-subtitle-language-item",
        toastText: "bpx-player-toast-text",
        toastItem: "bpx-player-toast-item"
    };

    function log ( message )
    {
        if ( CONFIG.debug )
        {
            console.log( "[Bilibili 自动开启字幕]", message );
            notification( message );
        }
    }

    /**
     * 显示通知, Hook 到 Bilibili 的通知系统
     * @param message {string}   希望显示的消息
     * @param timeoutOfMs {number}   通知显示的时长, 单位为毫秒
     */
    function notification ( message, timeoutOfMs = CONFIG.closeNotificationTimeoutMs )
    {
        const notificationBox = document.querySelector( selectorList.notificationBox );
        if ( !notificationBox )
        {
            // 在早期阶段可能找不到通知框,可以稍微延迟或忽略
            log( "未找到通知框" );
            return;
        }

        const notificationItem = document.createElement( "div" );
        notificationItem.className = classNameList.notificationItem;
        notificationItem.innerHTML =
            `<div class="${ classNameList.toastItem }"><span class="${ classNameList.toastText }">${ message }</span></div>`;

        notificationBox.appendChild( notificationItem );
        setTimeout( () => notificationItem.remove(), timeoutOfMs );
    }


    /**
     * 尝试开启字幕
     * @returns {boolean}  是否成功开启字幕
     */
    function enableSubtitle ()
    {
        /* order 越大优先级越高, 有特殊需求的字幕可以调整 order 值 */
        const priorityList = [
            { name: "官方英文", keyWord: "en-", order: 4 },
            { name: "AI英文", keyWord: "ai-en", order: 3 },
            { name: "官方中文", keyWord: "zh-", order: 2 },
            { name: "AI中文", keyWord: "ai-zh", order: 1 },
        ].sort( ( a, b ) => b.order - a.order );

        for ( const item of priorityList )
        {
            log( `扫描:正在查找 [${ item.name }] 字幕` );
            const languageButton = document.querySelector( `${ selectorList.subtitleItem }[data-lan*="${ item.keyWord }"]` );

            if ( !languageButton )
            {
                log( `扫描:未找到 [${ item.name }] 字幕` );
                continue;
            }

            // 检查字幕是否已开启
            if ( languageButton.classList.contains( classNameList.isActive ) )
            {
                log( `[${ item.name }] 字幕已是开启状态` );
                return true;
            }

            log( `操作:已尝试开启 [${ item.name }] 字幕` );
            languageButton.click();
            return true;
        }

        log( "扫描:未在列表中找到任何优先字幕" );
        return false;
    }

    // 监听锁,防止多次重复尝试开启字幕
    let isTryingToEnableSubtitle = false;

    function enableSubtitleWithRetry ( currentRetryCount = 0 )
    {
        isTryingToEnableSubtitle = true;
        if ( currentRetryCount >= CONFIG.maxRetryCount )
        {
            log( `状态:尝试开启字幕已达最大次数 ${ CONFIG.maxRetryCount },放弃...` );
            isTryingToEnableSubtitle = false;
            return;
        }

        if ( enableSubtitle() )
        {
            log( "状态:成功开启字幕..." );
            isTryingToEnableSubtitle = false;
            return;
        }

        log( `状态:播放器未加载,将在 ${ CONFIG.retryIntervalMs } 毫秒后再次尝试开启字幕... (${ currentRetryCount +
                                                                                               1 }/${ CONFIG.maxRetryCount })` );
        // 计算下一次重试的延迟时间, 根据当前重试次数和最大重试次数进行线性递增. 例如,第 1 次重试延迟 1 秒,第 2 次重试延迟 2 秒,以此类推。
        const nextRetryDelayMs = CONFIG.retryIntervalMs * ( currentRetryCount + 1 );
        setTimeout( () => enableSubtitleWithRetry( currentRetryCount + 1 ), nextRetryDelayMs );
    }

    function checkAndEnableSubtitle ( _, observer )
    {
        const videoPlayer = document.querySelector( selectorList.videoPlayer );

        if ( !videoPlayer )
        {
            log( "状态:播放器未加载,继续监听..." );
            return;
        }

        log( "状态:播放器已加载,停止监听..." );
        observer.disconnect();

        videoPlayer.addEventListener( "play", () =>
        {
            log( "状态:播放器开始播放,开始尝试开启字幕..." );
            if ( isTryingToEnableSubtitle )
            {
                log( "状态:正在尝试开启字幕,跳过..." );
                return;
            }
            enableSubtitleWithRetry();
        } );
    }


    function start ()
    {
        log( "状态:启动字幕自动开启脚本..." );
        const observer = new MutationObserver( checkAndEnableSubtitle );
        observer.observe( document.body, { childList: true, subtree: true } );
    }

    start();
} )();