YouTube 本地字幕載入器

讓 YouTube 能夠載入本地常用的各種字幕格式,支援srt/vtt/ass/ssa,按鈕在分享按鈕的後方

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         YouTube 本地字幕載入器
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  讓 YouTube 能夠載入本地常用的各種字幕格式,支援srt/vtt/ass/ssa,按鈕在分享按鈕的後方
// @author       shanlan(grok-code-fast-1)
// @match        https://www.youtube.com/watch*
// @grant        none
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function(){
  function unloadPreviousSubtitles(){
    const video = document.querySelector("video");
    if(video){
      const tracks = video.querySelectorAll('track[kind="subtitles"][label="Local"]');
      tracks.forEach(track => {
        video.removeChild(track);
      });
    }
  }

  function convertAssToVtt(assText){
    const lines = assText.split('\n');
    let inEvents = false;
    let vttContent = 'WEBVTT\n\n';
    let index = 1;

    for(const line of lines){
      if(line.trim() === '[Events]'){
        inEvents = true;
        continue;
      }
      if(inEvents && line.startsWith('Dialogue:')){
        const parts = line.split(',');
        if(parts.length >= 10){
          const startTime = parts[1].trim();
          const endTime = parts[2].trim();
          const text = parts.slice(9).join(',').replace(/\{.*?\}/g, '').trim();

          const vttStart = convertTime(startTime);
          const vttEnd = convertTime(endTime);

          vttContent += `${index}\n${vttStart} --> ${vttEnd}\n${text}\n\n`;
          index++;
        }
      }
    }

    return vttContent;
  }

  function convertTime(assTime){
    const parts = assTime.split(':');
    if(parts.length === 3){
      const [h, m, s] = parts;
      const [sec, centi] = s.split('.');
      const ms = centi ? parseInt(centi) * 10 : 0;
      return `${h.padStart(2, '0')}:${m.padStart(2, '0')}:${sec.padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
    }
    return assTime;
  }

  function injectUI(){
    const actionsContainer = document.querySelector('#below #actions');
    if(!actionsContainer) return;
    if(document.getElementById('local-subtitle-input-container')) return;
    
    const container = document.createElement("div");
    container.id = 'local-subtitle-input-container';
    container.style.cssText = `
      display: flex;
      align-items: center;
      margin: 0 0 0 8px;
    `;
    
    const input = document.createElement("input");
    input.type = "file";
    input.accept = ".srt,.vtt,.ass,.ssa";
    input.style = "display:none";
    input.id = 'local-subtitle-input';
    input.onchange = function(){
      var f = input.files[0];
      if(!f) return;
      unloadPreviousSubtitles();
      var r = new FileReader();
      r.onload = function(e){
        var txt = e.target.result;
        var isAss = f.name.toLowerCase().endsWith('.ass') || f.name.toLowerCase().endsWith('.ssa');
        if(isAss){
          txt = convertAssToVtt(txt);
        }else if(!txt.startsWith("WEBVTT")){
          txt = "WEBVTT\n\n" + txt.replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, "$1.$2");
        }
        txt = txt.replace('WEBVTT', 'WEBVTT\n\nSTYLE\n::cue {\n  background: rgba(0, 0, 0, 0.6); /* 半透明黑色背景 */\n}\n\n');
        var url = URL.createObjectURL(new Blob([txt], {type: "text/vtt"}));
        var v = document.querySelector("video");
        if(v){
          var t = document.createElement("track");
          t.kind = "subtitles";
          t.label = "Local";
          t.srclang = "zh-TW";
          t.src = url;
          t.default = true;
          v.appendChild(t);
        }
      };
      r.readAsText(f);
    };
    
    const label = document.createElement("label");
    label.htmlFor = 'local-subtitle-input';
    label.textContent = "載入字幕";
    label.style.cssText = `
      cursor: pointer;
      background-color: #272727;
      color: #f1f1f1;
      padding: 8px 12px;
      border-radius: 18px;
      font-size: 14px;
      font-weight: 500;
      transition: background-color 0.3s;
      white-space: nowrap;
    `;
    label.onmouseover = () => { label.style.backgroundColor = '#333333'; };
    label.onmouseout = () => { label.style.backgroundColor = '#272727'; };
    
    container.appendChild(input);
    container.appendChild(label);
    
    const shareButton = actionsContainer.querySelector('yt-button-view-model');
    if(shareButton){
      shareButton.parentNode.insertBefore(container, shareButton.nextSibling);
    }else{
      actionsContainer.appendChild(container);
    }
  }
  
  const observer = new MutationObserver((mutations, obs) => {
    if(document.querySelector('#below #actions')){
      injectUI();
    }
  });
  observer.observe(document.body, { childList: true, subtree: true });
})();