讓 YouTube 能夠載入本地常用的各種字幕格式,支援srt/vtt/ass/ssa,按鈕在分享按鈕的後方
// ==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 });
})();