Universal Subtitle Overlay (Responsive)

Load .srt or .vtt subtitles on any online video. Works on desktop, laptop, tablet, and mobile. Drag, sync, resize, overlay subtitles.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Universal Subtitle Overlay (Responsive)
// @namespace    https://greasyfork.org/users/1356925
// @version      20.0
// @description  Load .srt or .vtt subtitles on any online video. Works on desktop, laptop, tablet, and mobile. Drag, sync, resize, overlay subtitles.
// @author       You
// @match        *://*/*
// @license      MIT
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// ==/UserScript==

(() => {
'use strict';

/* ---------- STYLE (UI + Toast + Overlay) ---------- */
if (!document.getElementById('usub-style')) {
  const st = document.createElement('style');
  st.id = 'usub-style';
  st.textContent = `
    @import url("https://fonts.googleapis.com/css2?family=PT+Sans+Caption:wght@400;700&display=swap");
    :root { --primary-color:#adcaff; --background-color:#222f4d; --toast-bg:#1e2436ea; --toast-color:var(--primary-color); }
    .usub-over{
      position:fixed; left:50%; transform:translate(-50%,0); max-width:90vw;
      font-family:"PT Sans Caption",sans-serif; font-weight:700; line-height:1.28;
      color:#fff; text-shadow:0 2px 8px #000b,0 0 3px #000; z-index:2147483646!important;
      pointer-events:auto; text-align:center; white-space:pre-wrap; user-select:none;
    }
    #usub-loadbtn{
      position:fixed; bottom:2vh; right:2vw; background:var(--background-color)!important;
      color:var(--primary-color)!important; font-size:1.4rem!important; font-weight:600!important;
      padding:1rem 2rem!important; border-radius:1rem!important; border:none!important;
      cursor:pointer!important; z-index:2147483647!important; box-shadow:0 3px 8px rgba(0,0,0,.3)!important;
      touch-action: manipulation;
    }
    #usub-toast{
      position:fixed; left:50%; bottom:10vh; transform:translate(-50%,0);
      background:var(--toast-bg); color:var(--toast-color);
      font-size:1rem; padding:11px 23px; border-radius:12px;
      z-index:2147483647!important; opacity:0; transition:opacity .3s ease;
      white-space:normal; max-width:90vw; word-break:break-word; pointer-events:none; user-select:none;
    }
    #usub-toast.show{opacity:1;}
  `;
  document.head.appendChild(st);
}

/* ---------- CONFIG ---------- */
const CONFIG = {
  STORAGE_KEY:'usub-state-'+location.hostname+location.pathname,
  UI_GLOBAL_KEY:'usub-ui-visibility-global',
  BASE_VIDEO_WIDTH:640,
  MIN_FONT_SIZE:10,
  MAX_FONT_SIZE:60
};

/* ---------- STATE ---------- */
let state = {
  show:true,fontSize:18,bottom:10,x:50,sync:0,ui:true,
  raw:'',ext:'',fontWeight:700
};
let subs=[], overlay=null, video=null, toastEl=null;
let resizeThrottle=null, vidResizeObs=null, domObserver=null, mutationDebounce=null;
let eventListeners=[];

/* ---------- STORAGE ---------- */
const saveState = ()=>{ try{localStorage.setItem(CONFIG.STORAGE_KEY,JSON.stringify(state));}catch{} };
const loadState = ()=>{ try{Object.assign(state,JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEY))) }catch{} };
const loadGlobalUIState = ()=>{ try{let s=localStorage.getItem(CONFIG.UI_GLOBAL_KEY); if(s!==null) state.ui=s==='true';}catch{} };
const saveGlobalUIState = ()=>{ try{localStorage.setItem(CONFIG.UI_GLOBAL_KEY,String(state.ui));}catch{} };

/* ---------- TOAST ---------- */
let toastTimer=null;
const showToast = (msg,dur=3000)=>{
  if(!toastEl){ toastEl=document.createElement('div'); toastEl.id='usub-toast'; document.body.appendChild(toastEl); }
  toastEl.textContent=msg;
  toastEl.classList.remove('show'); void toastEl.offsetWidth;
  toastEl.classList.add('show');
  clearTimeout(toastTimer);
  toastTimer=setTimeout(()=>toastEl.classList.remove('show'),dur);
};

/* ---------- SUB PARSER ---------- */
const parseTimestamp = ts=>{
  const m = ts.trim().match(/(\d+):(\d{2}):(\d{2})[.,](\d{3})/);
  return m ? (+m[1])*3600 + (+m[2])*60 + (+m[3]) + (+m[4])/1000 : 0;
};
const parseSubtitles = raw=>{
  raw = raw.replace(/^\uFEFF/,'');
  return raw.split(/\n{2,}/).reduce((acc,blk)=>{
    const lines = blk.trim().split(/\r?\n/);
    const idx = lines.findIndex(l=>l.includes('-->'));
    if(idx<0) return acc;
    const [start,end] = lines[idx].split('-->').map(s=>s.trim());
    const st=parseTimestamp(start), et=parseTimestamp(end);
    if(!st || !et || st>=et) return acc;
    const text = lines.slice(idx+1).join('\n')
      .replace(/\{\\i1\}/g,'<em>').replace(/\{\\i0\}/g,'</em>')
      .replace(/<i>/g,'<em>').replace(/<\/i>/g,'</em>');
    acc.push({start:st,end:et,text});
    return acc;
  },[]);
};

/* ---------- VIDEO LOCATOR ---------- */
const findVideo=()=>{
  const vids=[...document.querySelectorAll('video')].filter(v=>{
    if(!v.currentSrc||!document.body.contains(v)) return false;
    const r=v.getBoundingClientRect();
    if(r.width<100||r.height<80||r.bottom<=0||r.top>=innerHeight) return false;
    const s=getComputedStyle(v);
    return s.visibility!=='hidden' && s.display!=='none';
  });
  if(!vids.length) return null;
  vids.sort((a,b)=>b.offsetWidth*b.offsetHeight - a.offsetWidth*a.offsetHeight);
  return vids[0];
};

/* ---------- OVERLAY ---------- */
const cleanupListeners=()=>{
  for(const o of eventListeners){
    o.el.removeEventListener('pointerdown',o.down);
    o.el.removeEventListener('pointermove',o.move);
    o.el.removeEventListener('pointerup',o.up);
    o.el.removeEventListener('pointercancel',o.up);
  }
  eventListeners=[];
};
const clamp=(v,min,max)=>Math.min(max,Math.max(min,v));

const enableDrag = el=>{
  let dragging=false,sx,sy,startX,startBottom;
  const down=e=>{
    dragging=true; sx=e.clientX; sy=e.clientY;
    startX=state.x; startBottom=state.bottom;
    document.body.style.userSelect='none';
    el.setPointerCapture(e.pointerId); e.preventDefault();
  };
  const move=e=>{
    if(!dragging||!video) return;
    const r=video.getBoundingClientRect();
    state.x = clamp(startX + ((e.clientX-sx)/r.width)*100,5,95);
    state.bottom = clamp(startBottom - ((e.clientY-sy)/r.height)*100,0,60);
    updateOverlayStyle();
  };
  const up=e=>{
    if(!dragging) return;
    dragging=false; document.body.style.userSelect='';
    el.releasePointerCapture(e.pointerId); saveState();
    showToast('Position saved');
  };
  el.addEventListener('pointerdown',down);
  el.addEventListener('pointermove',move);
  el.addEventListener('pointerup',up);
  el.addEventListener('pointercancel',up);
  eventListeners.push({el,down,move,up});
};

const createOverlay=()=>{
  if(overlay){ overlay.remove(); cleanupListeners(); }
  overlay=document.createElement('div'); overlay.className='usub-over';
  document.body.appendChild(overlay); enableDrag(overlay);
};

/* ---------- OVERLAY UPDATE ---------- */
const updateOverlayStyle=()=>{
  if(!overlay||!video) return;
  overlay.style.fontWeight=state.fontWeight;
  let size = state.fontSize * Math.min(video.offsetWidth/CONFIG.BASE_VIDEO_WIDTH, window.innerWidth/CONFIG.BASE_VIDEO_WIDTH) * window.devicePixelRatio;
  overlay.style.fontSize=clamp(size,CONFIG.MIN_FONT_SIZE,CONFIG.MAX_FONT_SIZE)+'px';
  const r=video.getBoundingClientRect();
  overlay.style.left = (r.left + r.width*state.x/100)+'px';
  overlay.style.bottom = (innerHeight - r.bottom + r.height*state.bottom/100)+'px';
  overlay.style.transform='translateX(-50%)';
  overlay.style.display = (state.show && subs.length>0 && !document.getElementById('usub-guide-overlay'))?'block':'none';
};

const updateSubtitles=()=>{
  if(!video||!overlay||!state.show){ if(overlay) overlay.style.display='none'; return; }
  const t=video.currentTime+state.sync;
  const sub=subs.find(s=>t>=s.start&&t<=s.end);
  const text=sub?sub.text:'';
  if(overlay.innerHTML!==text) overlay.innerHTML=text;
  updateOverlayStyle();
};

/* ---------- FILE LOADER ---------- */
const handleFileInput=e=>{
  const f=e.target.files[0];
  if(!f) return showToast('No file selected');
  const ext=f.name.split('.').pop().toLowerCase();
  if(!['srt','vtt'].includes(ext)) return showToast('Select .srt or .vtt');
  const r=new FileReader();
  r.onload=ev=>{
    state.raw=ev.target.result; state.ext=ext; saveState();
    subs=parseSubtitles(state.raw);
    showToast(`Loaded: ${f.name}`);
    updateSubtitles();
  };
  r.readAsText(f,'UTF-8');
};

/* ---------- UI ---------- */
const initializeUI=()=>{
  loadGlobalUIState();
  let btn=document.getElementById('usub-loadbtn');
  if(!btn){
    btn=document.createElement('button');
    btn.id='usub-loadbtn'; btn.textContent='Load Subtitle';
    document.body.appendChild(btn);
  }
  btn.style.display=state.ui?'block':'none';
  let input=document.getElementById('usub-file-input');
  if(!input){
    input=document.createElement('input');
    input.id='usub-file-input'; input.type='file';
    input.accept='.srt,.vtt'; input.style.display='none';
    document.body.appendChild(input);
  }
  btn.onclick=()=>{ input.value=''; input.click(); };
  input.onchange=handleFileInput;
};

/* ---------- MENU COMMANDS ---------- */
const registerCommands=()=>{
  if(typeof GM_registerMenuCommand!=='function') return;
  GM_registerMenuCommand('UI Button ON/OFF',()=>{
    state.ui=!state.ui;
    document.getElementById('usub-loadbtn').style.display=state.ui?'block':'none';
    saveGlobalUIState();
  });
  GM_registerMenuCommand('Subtitles ON/OFF',()=>{
    state.show=!state.show; saveState(); updateSubtitles();
  });
  GM_registerMenuCommand('Sync Offset',()=>{
    const v=parseFloat(prompt('Offset in seconds:',state.sync));
    if(!isNaN(v)){ state.sync=v; saveState(); }
  });
  GM_registerMenuCommand('Vertical Position (%)',()=>{
    const v=parseFloat(prompt('0–60%',state.bottom));
    if(!isNaN(v)){ state.bottom=clamp(v,0,60); saveState(); }
  });
  GM_registerMenuCommand('Font Size (px)',()=>{
    const v=parseInt(prompt('10–60px',state.fontSize));
    if(!isNaN(v)){ state.fontSize=clamp(v,10,60); saveState(); }
  });
  GM_registerMenuCommand('Font Weight (400/700)',()=>{
    const v=parseInt(prompt('400 or 700',state.fontWeight));
    if(v===400||v===700){ state.fontWeight=v; saveState(); }
  });
  GM_registerMenuCommand('Clear All Subtitle Data',()=>{
    if(confirm('Clear subtitles and reset settings?')){
      Object.assign(state,{show:true,fontSize:18,bottom:10,x:50,sync:0,ui:true,raw:'',ext:'',fontWeight:700});
      saveState(); subs=[]; if(overlay) overlay.innerHTML=''; updateOverlayStyle();
    }
  });
};

/* ---------- INIT ---------- */
const observeVideo=vid=>{
  if(vidResizeObs) vidResizeObs.disconnect();
  vidResizeObs=new ResizeObserver(()=>{
    if(resizeThrottle) return;
    resizeThrottle=setTimeout(()=>{
      updateOverlayStyle(); updateSubtitles(); resizeThrottle=null;
    },100);
  });
  vidResizeObs.observe(vid);
};

const init=()=>{
  video=findVideo();
  if(!video) return;
  createOverlay(); observeVideo(video);
  video.addEventListener('timeupdate',updateSubtitles);
  updateOverlayStyle(); updateSubtitles();
};

const monitorFullscreen = () => {
    document.addEventListener('fullscreenchange', () => {
        const fs = document.fullscreenElement;
        if (fs && overlay) {
            fs.appendChild(overlay);
        } else if (overlay) {
            document.body.appendChild(overlay);
        }
        updateOverlayStyle();
    });
};
monitorFullscreen();

/* ---------- DOM OBSERVER ---------- */
domObserver=new MutationObserver(()=>{
  clearTimeout(mutationDebounce);
  mutationDebounce=setTimeout(()=>{
    if(!video||!document.body.contains(video)) init();
  },150);
});
domObserver.observe(document.body,{childList:true,subtree:true});

/* ---------- WINDOW EVENTS ---------- */
window.addEventListener('resize', ()=>{ updateOverlayStyle(); updateSubtitles(); });
window.addEventListener('orientationchange', ()=>{ updateOverlayStyle(); updateSubtitles(); });

/* ---------- STARTUP ---------- */
loadState(); loadGlobalUIState();
initializeUI(); registerCommands();
if(state.raw) subs=parseSubtitles(state.raw);
init();

})();