YouTube Local Subtitle Loader

Adds a button to load local SRT or VTT subtitle files on YouTube. More robust version.

当前为 2025-08-23 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YouTube Local Subtitle Loader
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  Adds a button to load local SRT or VTT subtitle files on YouTube. More robust version.
// @match        https://www.youtube.com/watch?*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- 在这里自定义字幕样式 ---
    const subtitleStyle = `
        position: absolute;
        bottom: 8%; /* 字幕距离底部的距离 */
        width: 90%; /* 字幕区域宽度 */
        left: 5%;   /* 居中 */
        text-align: center;
        pointer-events: none;
        z-index: 99999; /* 确保在最顶层,避免被遮挡 */
        font-size: 2.2em; /* 字体大小 */
        color: white; /* 字体颜色 */
        font-weight: bold;
        text-shadow: 2px 2px 2px #000000, -2px -2px 2px #000000, 2px -2px 2px #000000, -2px 2px 2px #000000; /* 黑色描边,增强可读性 */
        font-family: Arial, "Heiti SC", "Microsoft Yahei", sans-serif;
        line-height: 1.4;
    `;

    let subtitles = [];
    let videoElement = null;
    let subtitleContainer = null;
    let animationFrameId = null;

    // 将时间字符串 (HH:MM:SS,ms) 转换为秒
    function timeToSeconds(timeString) {
        const parts = timeString.replace('.', ',').split(/[:,]/);
        if (parts.length < 3) return 0; // 容错处理,至少有 M:S,ms
        const milliseconds = parseInt(parts.pop(), 10) || 0;
        const seconds = parseInt(parts.pop(), 10) || 0;
        const minutes = parseInt(parts.pop(), 10) || 0;
        const hours = parseInt(parts.pop(), 10) || 0;
        return (hours * 3600) + (minutes * 60) + seconds + (milliseconds / 1000);
    }

    // 解析 SRT/VTT 字幕内容 (兼容两种格式)
    function parseSubtitles(subtitleContent) {
        const lines = subtitleContent.replace(/\r/g, '').split('\n\n');
        const subs = [];
        for (const line of lines) {
            const parts = line.split('\n');
            if (parts.length < 2) continue;

            const timeMatch = parts.find(p => p.includes('-->'));
            if (!timeMatch) continue;

            const timeParts = timeMatch.split(' --> ');
            if (timeParts.length !== 2) continue;

            const startTime = timeToSeconds(timeParts[0]);
            const endTime = timeToSeconds(timeParts[1]);
            const text = parts.slice(parts.indexOf(timeMatch) + 1).join('\n').trim();

            if (text) {
                subs.push({ start: startTime, end: endTime, text: text });
            }
        }
        return subs;
    }


    // 更新字幕显示
    function updateSubtitles() {
        if (!videoElement || !subtitleContainer) {
            animationFrameId = requestAnimationFrame(updateSubtitles);
            return;
        }

        const currentTime = videoElement.currentTime;
        const currentSub = subtitles.find(sub => currentTime >= sub.start && currentTime <= sub.end);

        if (currentSub && subtitleContainer.innerHTML !== currentSub.text) {
            subtitleContainer.innerHTML = currentSub.text.replace(/\n/g, '<br>');
            subtitleContainer.style.visibility = 'visible';
        } else if (!currentSub && subtitleContainer.innerHTML !== '') {
            subtitleContainer.innerHTML = '';
            subtitleContainer.style.visibility = 'hidden';
        }
        animationFrameId = requestAnimationFrame(updateSubtitles);
    }


    // 处理文件选择
    function handleFileSelect(event) {
        const file = event.target.files[0];
        if (!file) return;

        const reader = new FileReader();
        reader.onload = function(e) {
            const content = e.target.result;
            subtitles = parseSubtitles(content);

            if(subtitles.length > 0){
                alert(`字幕加载成功!共 ${subtitles.length} 条。`);
                if (animationFrameId) {
                    cancelAnimationFrame(animationFrameId);
                }
                updateSubtitles();
            } else {
                alert('无法解析字幕文件,请检查文件格式是否正确 (支持 SRT 和 VTT) 或文件编码是否为 UTF-8。');
            }
        };
        reader.readAsText(file, 'UTF-8');
    }

    // 创建并注入UI元素
    function injectUI() {
        const player = document.querySelector('#movie_player');
        // 新的按钮位置,在点赞按钮的容器里
        const actionsContainer = document.querySelector('#below #actions');

        if (!player || !actionsContainer) {
            return; // 如果元素未找到,则不执行任何操作
        }

        // --- 创建字幕显示容器 (如果不存在) ---
        if (!document.getElementById('custom-subtitle-display')) {
            subtitleContainer = document.createElement('div');
            subtitleContainer.id = 'custom-subtitle-display';
            subtitleContainer.style.cssText = subtitleStyle;
            player.appendChild(subtitleContainer);
            console.log('Subtitle container injected.');
        }

        // --- 创建文件选择器 (如果不存在) ---
        if (!document.getElementById('local-subtitle-input-container')) {
            const container = document.createElement('div');
            container.id = 'local-subtitle-input-container';
            container.style.cssText = `
                display: flex;
                align-items: center;
                margin: 0 8px;
            `;

            const fileInput = document.createElement('input');
            fileInput.type = 'file';
            fileInput.accept = '.srt,.vtt';
            fileInput.style.display = 'none';
            fileInput.id = 'local-subtitle-input';
            fileInput.addEventListener('change', handleFileSelect);

            const fileInputLabel = document.createElement('label');
            fileInputLabel.htmlFor = 'local-subtitle-input';
            fileInputLabel.textContent = '加载字幕';
            fileInputLabel.style.cssText = `
                cursor: pointer;
                background-color: #eee;
                color: #333;
                padding: 8px 12px;
                border-radius: 18px;
                font-size: 14px;
                font-weight: 500;
                transition: background-color 0.3s;
                white-space: nowrap; /* 关键改动:强制不换行 */
            `;
            // 适配暗色模式
            if(document.querySelector('html[dark=true]')) {
                 fileInputLabel.style.backgroundColor = '#3f3f3f';
                 fileInputLabel.style.color = '#fff';
            }

            fileInputLabel.onmouseover = () => { fileInputLabel.style.backgroundColor = '#ccc'; };
            fileInputLabel.onmouseout = () => {
                 fileInputLabel.style.backgroundColor = document.querySelector('html[dark=true]') ? '#3f3f3f' : '#eee';
            };


            container.appendChild(fileInput);
            container.appendChild(fileInputLabel);

            // 插入到 "分享" 按钮后面
            const shareButton = actionsContainer.querySelector('ytd-button-renderer:nth-child(2)');
            if (shareButton) {
                shareButton.parentNode.insertBefore(container, shareButton.nextSibling);
            } else {
                 actionsContainer.appendChild(container); // 备用方案
            }
            console.log('UI button injected.');
        }


        // 获取 video 元素
        videoElement = document.querySelector('#movie_player video');
        if (!videoElement) {
             console.error("Could not find video element.");
             return;
        }

        // 确保字幕循环只在需要时启动
        if (!animationFrameId) {
             updateSubtitles();
        }
    }

    // 使用 MutationObserver 来确保在页面动态加载完成后执行脚本
    const observer = new MutationObserver((mutations, obs) => {
        if (document.querySelector('#below #actions') && document.querySelector('#movie_player video')) {
            injectUI();
            obs.disconnect(); // 找到元素后停止观察,避免重复执行
        }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });
})();