成人交流 · text · 0 分 · 评论 0 · 阅读 1

一个115网盤搜索字幕的油猴脚本

最近LTX破解爆發,沒有字幕不方便理解劇情的需求越來越大。
參考115master https://github.com/zzm432/115master 的脚本,
精簡播放器的部分保留字幕和預覽。

主要功能:
1、需要時點開,不占據屏幕可視空間。
2、可搜索,可編輯,可預覽【試播】。同時可以查看字幕結束時間,對照視頻長度提前發現不合格的字幕。
3、有些視頻是藍光壓制,可能有偏移。可以手動調整,並保存爲調整偏移后的字幕。

唯一的問題,下載到本地后需要上傳到當前視頻目錄,才能永久保存。

使用方法:
安裝油猴或篡改猴等插件到瀏覽器,管理插件,新建,將下面的代碼複製并保存即可。






// ==UserScript==
// [url=home.php?mod=space&uid=264574]@name[/url]         115字幕助手 2.0(播放器版)
// @namespace    115-Subtitle-Helper-Player
// [url=home.php?mod=space&uid=361048]@Version[/url]      2.0.4
// @description  115网页原生播放器外挂字幕:迅雷/SubtitleCat搜索、试播、字幕拖动、双语字号、时间偏移和下载
// @author       Community adaptation
// @license      MIT
// @match        https://115vod.com/*
// @match        https://*.115vod.com/*
// unable: @match        https://115.com/*
// unable: @match        https://*.115.com/*
// @connect      api-shoulei-ssl.xunlei.com
// @connect      subtitlecat.com
// @connect      *.subtitlecat.com
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @run-at       document-idle
// @noframes
// ==/UserScript==

(() => {
  'use strict';
  if (window.__SH115_PLAYER_V2__) return;
  window.__SH115_PLAYER_V2__ = true;

  const K = 'sh115v2';
  const $ = id => document.getElementById(`${K}-${id}`);
  const DEFAULTS = { zhSize: 30, enSize: 23, bottom: 9, opacity: 0, outline: true, inverted: false };
  const MIN_MATCH = 0.33;
  const storageKey = `${K}-settings`;
  const uiStorageKey = `${K}-ui`;
  const ui = (() => {
    try { return JSON.parse(localStorage.getItem(uiStorageKey) || '{}') || {}; }
    catch { return {}; }
  })();
  const saveUI = () => { try { localStorage.setItem(uiStorageKey, JSON.stringify(ui)); } catch {} };
  const settings = (() => {
    try { return { ...DEFAULTS, ...JSON.parse(localStorage.getItem(storageKey) || '{}') }; }
    catch { return { ...DEFAULTS }; }
  })();
  const state = {
    video: null, overlay: null, panel: null, launcher: null, active: null, items: [], cues: [],
    fileName: '', activeRaw: null, searchToken: 0, subtitlesToken: 0,
    offset: 0, editing: false, enabled: true, expanded: true,
    lastPickCode: '', lastPagePath: location.pathname + location.search,
    message: '打开视频后搜索外部字幕', busy: false
  };
  const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
  const saveSettings = () => localStorage.setItem(storageKey, JSON.stringify(settings));
  const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
  const stripExt = s => String(s || '').replace(/\.(mp4|mkv|avi|mov|webm|wmv|flv|ts|m2ts|rmvb?|srt|ass|ssa|vtt)$/i, '');
  const normalize = s => stripExt(s).toLowerCase()
    .replace(/[\[【((][^\]】))]{0,60}[\]】))]/g, ' ')
    .replace(/\b(2160p|1080p|720p|480p|4k|uhd|bluray|blu-ray|bdrip|webrip|web-dl|hdtv|remux|x26[45]|h\.?26[45]|hevc|aac|dts|hdr|10bit)\b/gi, ' ')
    .replace(/[._\-]+/g, ' ').replace(/\s+/g, ' ').trim();
  function codeOf(s) {
    const m = String(s).toUpperCase().match(/(?:^|[^A-Z0-9])([A-Z]{2,8})[-_ ]?(\d{2,6})(?=$|[^A-Z0-9])/);
    return m ? `${m[1]}-${m[2]}` : '';
  }
  function episodeOf(s) {
    let m = String(s).match(/\bS(\d{1,2})[ ._-]*E(\d{1,3})\b/i);
    if (m) return [Number(m[1]), Number(m[2])];
    m = String(s).match(/第\s*(\d{1,3})\s*[集话]/);
    return m ? [null, Number(m[1])] : null;
  }
  function similarity(a, b) {
    const x = normalize(a), y = normalize(b);
    if (!x || !y) return 0;
    const cx = codeOf(a), cy = codeOf(b);
    if (cx && cy) return cx === cy ? 0.99 : 0;
    if (cy && !cx) return 0;
    const ex = episodeOf(a), ey = episodeOf(b);
    if (ex && ey && (ex[1] !== ey[1] || (ex[0] !== null && ey[0] !== null && ex[0] !== ey[0]))) return 0;
    if (x === y) return 1;
    const grams = text => {
      const v = text.replace(/\s/g, '');
      const out = new Set();
      for (let i = 0; i < v.length - 1; i++) out.add(v.slice(i, i + 2));
      return out;
    };
    const g1 = grams(x), g2 = grams(y);
    let common = 0;
    g1.forEach(g => { if (g2.has(g)) common++; });
    const dice = (g1.size + g2.size) ? 2 * common / (g1.size + g2.size) : 0;
    const tx = new Set(x.split(' ')), ty = new Set(y.split(' '));
    const tokens = 2 * [...tx].filter(t => ty.has(t)).length / (tx.size + ty.size);
    return clamp(Math.max(dice * .78 + tokens * .22, x.includes(y) || y.includes(x) ? .79 : 0) + (ex && ey ? .05 : 0), 0, 1);
  }
  function filenameFromPage() {
    const selectors = [
      '.video-title', '.player-title', '.film-title', '[class*=videoName]',
      '[class*=fileName]', '[class*=video-title]', '.header-title', '.title'
    ];
    for (const sel of selectors) {
      for (const el of document.querySelectorAll(sel)) {
        const text = (el.getAttribute('title') || el.textContent || '').trim();
        if (text && text.length < 180 && /\.(mp4|mkv|avi|mov|ts|webm|rmvb?)\b/i.test(text)) return text;
      }
    }
    const title = document.title.replace(/\s*[-|_]\s*115.*$/i, '').trim();
    return title.length > 3 && !/^(115|在线播放|视频播放)$/i.test(title) ? title : '';
  }
  function currentPickCode() {
    const q = new URLSearchParams(location.search);
    return q.get('pickcode') || q.get('pick_code') || '';
  }
  function isVideoVisible(v) {
    const r = v.getBoundingClientRect();
    return r.width > 120 && r.height > 80;
  }
  function findVideo() {
    const videos = [...document.querySelectorAll('video')];
    videos.sort((a, b) => (Number(isVideoVisible(b)) * 1e7 + Number(!b.paused) * 1e6 + b.clientWidth * b.clientHeight) - (Number(isVideoVisible(a)) * 1e7 + Number(!a.paused) * 1e6 + a.clientWidth * a.clientHeight));
    return videos[0] || null;
  }
  function status(s, error = false) {
    state.message = s;
    const el = $('status');
    if (el) { el.textContent = s; el.style.color = error ? '#ff9898' : ''; }
  }
  function request(url, responseType = 'text') {
    if (!/^https:\/\//i.test(url)) return Promise.reject(new Error('只允许 HTTPS 字幕地址'));
    return new Promise((resolve, reject) => GM_xmlhttpRequest({
      method: 'GET', url, responseType, timeout: 30000,
      onload: r => r.status >= 200 && r.status < 400 ? resolve(r.response) : reject(new Error(`请求失败 HTTP ${r.status}`)),
      ontimeout: () => reject(new Error('请求超时')),
      onerror: () => reject(new Error('网络请求失败'))
    }));
  }
  async function thunder(keyword) {
    const json = JSON.parse(await request(`https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name=${encodeURIComponent(keyword)}`));
    if (json.code !== 0 || json.result !== 'ok' || !Array.isArray(json.data)) return [];
    return json.data.filter(x => x.url && /^https:\/\//i.test(x.url)).map(x => ({
      title: x.name || x.extra_name || '迅雷字幕', source: '迅雷', format: x.ext || '', score: Number(x.score) || 0,
      fetch: () => request(x.url, 'arraybuffer')
    }));
  }
  async function subtitleCat(keyword) {
    const html = await request(`https://subtitlecat.com/index.php?search=${encodeURIComponent(keyword)}`);
    const doc = new DOMParser().parseFromString(html, 'text/html');
    return [...doc.querySelectorAll('.sub-table tbody tr')].slice(0, 12).flatMap((row, index) => {
      const link = row.querySelector('td:first-child a');
      const href = link?.getAttribute('href'), title = link?.textContent?.trim();
      if (!href || !title) return [];
      const detailUrl = new URL(href, 'https://subtitlecat.com/');
      if (!/^https?:$/.test(detailUrl.protocol) || !/(^|\.)subtitlecat\.com$/i.test(detailUrl.hostname)) return [];
      return [{ title, source: 'SubtitleCat', format: 'srt', score: 0, index,
        fetch: async () => {
          const page = new DOMParser().parseFromString(await request(detailUrl.href), 'text/html');
          const links = ['#download_zh-CN', '#download_zh', '#download_zh-TW'];
          const selected = links.map(sel => page.querySelector(sel)?.getAttribute('href')).find(Boolean);
          if (!selected) throw new Error('SubtitleCat该条目没有可用的中文字幕下载链接');
          const download = new URL(selected, detailUrl);
          if (download.protocol !== 'https:') throw new Error('字幕下载地址不是HTTPS');
          return request(download.href, 'arraybuffer');
        }
      }];
    });
  }
  async function search() {
    const keyword = $('keyword').value.trim();
    if (!keyword) return status('请输入影片名称或番号', true);
    const token = ++state.searchToken;
    state.busy = true;
    $('search').disabled = true;
    $('results').replaceChildren();
    status('正在搜索迅雷与SubtitleCat……');
    const settled = await Promise.allSettled([thunder(keyword), subtitleCat(keyword)]);
    if (token !== state.searchToken) return;
    state.busy = false;
    $('search').disabled = false;
    const entries = settled.filter(r => r.status === 'fulfilled').flatMap(r => r.value)
      .map(r => ({ ...r, match: similarity(r.title, keyword) }))
      .filter(r => r.match >= MIN_MATCH)
      .sort((a, b) => b.match - a.match || b.score - a.score);
    const known = new Set();
    state.items = entries.filter(r => {
      const key = `${r.source}:${r.title.toLowerCase()}`;
      if (known.has(key)) return false;
      known.add(key);
      return true;
    });
    renderResults();
    const fail = settled.filter(r => r.status === 'rejected');
    if (fail.length) status(`找到 ${state.items.length} 条;${fail.length} 个来源请求失败(可能是接口变化或网络限制)`, true);
    else status(`找到 ${state.items.length} 条候选字幕,点击“试播”校对`);
  }
  function decodeSubtitle(buffer) {
    const bytes = new Uint8Array(buffer);
    if (bytes.length > 3e7) throw new Error('字幕文件过大');
    if (bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder('utf-16le').decode(buffer);
    if (bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder('utf-16be').decode(buffer);
    try { return new TextDecoder('utf-8', { fatal: true }).decode(buffer); }
    catch { return new TextDecoder('gb18030').decode(buffer); }
  }
  function parseClock(s) {
    const m = String(s).trim().match(/^(\d{1,3}):(\d\d):(\d\d)[,.](\d{1,3})$/);
    if (!m) return NaN;
    return ((Number(m[1]) * 60 + Number(m[2])) * 60 + Number(m[3])) * 1000 + Number(m[4].padEnd(3, '0'));
  }
  function cleanTags(text) { return text.replace(/<[^>]*>/g, '').replace(/\{\\[^}]*\}/g, '').replace(/\\N|\\n/g, '\n').trim(); }
  function parseSRT(text) {
    return text.replace(/^\uFEFF/, '').replace(/\r/g, '').split(/\n\s*\n/).flatMap(block => {
      const lines = block.trim().split('\n');
      const i = lines.findIndex(line => /-->/.test(line));
      if (i < 0) return [];
      const m = lines[i].match(/(\d{1,3}:\d\d:\d\d[,.]\d{1,3})\s*-->\s*(\d{1,3}:\d\d:\d\d[,.]\d{1,3})/);
      if (!m) return [];
      const start = parseClock(m[1]), end = parseClock(m[2]);
      if (!(end > start)) return [];
      const body = cleanTags(lines.slice(i + 1).join('\n'));
      return body ? [{ start, end, text: body }] : [];
    });
  }
  function parseASS(text) {
    const lines = text.replace(/\r/g, '').split('\n');
    let inEvents = false, format = null;
    const cues = [];
    for (const line of lines) {
      if (/^\s*\[Events\]/i.test(line)) { inEvents = true; continue; }
      if (/^\s*\[.+\]/.test(line)) { inEvents = false; continue; }
      if (!inEvents) continue;
      if (/^\s*Format\s*:/i.test(line)) {
        format = line.replace(/^\s*Format\s*:/i, '').split(',').map(x => x.trim().toLowerCase());
      }
      if (!/^\s*Dialogue\s*:/i.test(line)) continue;
      const body = line.replace(/^\s*Dialogue\s*:/i, '');
      const cols = format || ['layer', 'start', 'end', 'style', 'name', 'marginl', 'marginr', 'marginv', 'effect', 'text'];
      const parts = body.split(',');
      if (parts.length < cols.length) continue;
      const fixed = parts.slice(0, cols.length - 1).concat([parts.slice(cols.length - 1).join(',')]);
      const start = parseClock(fixed[cols.indexOf('start')]);
      const end = parseClock(fixed[cols.indexOf('end')]);
      const raw = fixed[cols.indexOf('text')];
      if (!(end > start) || !raw) continue;
      const value = cleanTags(raw);
      if (value) cues.push({ start, end, text: value });
    }
    return cues;
  }
  function parseSubtitle(text, format) {
    let kind = /^ass|ssa$/i.test(format) || /^\s*\[Script Info\]/mi.test(text) ? 'ass' : 'srt';
    let cues = kind === 'ass' ? parseASS(text) : parseSRT(text);
    if (!cues.length) { kind = kind === 'ass' ? 'srt' : 'ass'; cues = kind === 'ass' ? parseASS(text) : parseSRT(text); }
    return { kind, cues: cues.sort((a, b) => a.start - b.start) };
  }
  async function preview(item) {
    const token = ++state.subtitlesToken;
    status(`正在获取并解析:${item.title}`);
    try {
      const buffer = await item.fetch();
      if (token !== state.subtitlesToken) return;
      const text = decodeSubtitle(buffer);
      const parsed = parseSubtitle(text, item.format);
      if (!parsed.cues.length) throw new Error('未解析到有效字幕;可能是压缩包或不支持的格式');
      state.active = item;
      state.activeRaw = { text, kind: parsed.kind };
      state.cues = parsed.cues;
      state.offset = 0;
      state.enabled = true;
      $('offset').value = '0.0';
      $('enabled').checked = true;
      renderResults();
      updateCue();
      status(`试播中:${parsed.cues.length} 条字幕;末条结束 ${clock(parsed.cues[parsed.cues.length - 1].end)}`);
    } catch (e) { if (token === state.subtitlesToken) status(`预览失败:${e.message}`, true); }
  }
  function clock(ms) {
    const v = Math.max(0, Math.round(ms));
    const hours = Math.floor(v / 3600000), minutes = Math.floor(v / 60000) % 60, seconds = Math.floor(v / 1000) % 60, centis = Math.floor(v % 1000 / 10);
    return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(centis).padStart(2, '0')}`;
  }
  function srtStamp(ms) {
    const n = Math.max(0, Math.round(ms));
    const h = Math.floor(n / 3600000), m = Math.floor(n / 60000) % 60, s = Math.floor(n / 1000) % 60, milli = n % 1000;
    return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(milli).padStart(3, '0')}`;
  }
  function shiftedDownload() {
    return state.cues.map((cue, i) => {
      const start = cue.start + state.offset * 1000;
      const end = cue.end + state.offset * 1000;
      if (end <= 0) return null;
      return `${i + 1}\n${srtStamp(Math.max(0, start))} --> ${srtStamp(Math.max(0, end))}\n${cue.text}`;
    }).filter(Boolean).join('\n\n') + '\n';
  }
  function assStamp(ms) {
    const n = Math.max(0, Math.round(ms / 10));
    const h = Math.floor(n / 360000), m = Math.floor(n / 6000) % 60;
    const sec = Math.floor(n / 100) % 60, cs = n % 100;
    return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
  }
  function shiftedASS() {
    // 保留ASS/SSA其他内容(样式/特效/注释),只更新Events的时间戳。
    let inEvents = false, cols = null;
    return state.activeRaw.text.split(/(\r?\n)/).map(segment => {
      if (/^\r?\n$/.test(segment)) return segment;
      if (/^\s*\[Events\]\s*$/i.test(segment)) { inEvents = true; cols = null; return segment; }
      if (/^\s*\[[^\]]+\]\s*$/i.test(segment)) { inEvents = false; return segment; }
      if (!inEvents) return segment;
      const fmt = segment.match(/^\s*Format\s*:(.*)$/i);
      if (fmt) { cols = fmt[1].split(',').map(x => x.trim().toLowerCase()); return segment; }
      const dlg = segment.match(/^(\s*Dialogue\s*:)(.*)$/i);
      if (!dlg) return segment;
      const columns = cols || ['layer','start','end','style','name','marginl','marginr','marginv','effect','text'];
      const startIdx = columns.indexOf('start'), endIdx = columns.indexOf('end');
      if (startIdx < 0 || endIdx < 0) return segment;
      const parts = dlg[2].split(',');
      if (parts.length < columns.length) return segment;
      const start = parseClock(parts[startIdx]), end = parseClock(parts[endIdx]);
      if (!Number.isFinite(start) || !Number.isFinite(end)) return segment;
      const delta = state.offset * 1000;
      parts[startIdx] = assStamp(start + delta);
      parts[endIdx] = assStamp(Math.max(0, end + delta));
      return dlg[1] + parts.join(',');
    }).join('');
  }
  function saveBlob(text, name) {
    const url = URL.createObjectURL(new Blob(['\uFEFF', text], { type: 'text/plain;charset=utf-8' }));
    const a = document.createElement('a');
    a.href = url;
    a.download = name.replace(/[\\/:*?"<>|]/g, '_');
    document.body.appendChild(a); a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 10000);
  }
  function download() {
    if (!state.activeRaw || !state.active) return status('请先试播一条字幕', true);
    const adjusted = $('adjusted').checked;
    const kind = state.activeRaw.kind;
    const extension = adjusted ? (kind === 'ass' ? 'ass' : 'srt') : kind;
    const suffix = adjusted ? `.adjusted.${extension}` : `.${extension}`;
    const base = stripExt(state.active.title).replace(/\.(srt|ass|ssa)$/i, '');
    const name = `${base}${suffix}`;
    const output = adjusted ? (kind === 'ass' ? shiftedASS() : shiftedDownload()) : state.activeRaw.text;
    saveBlob(output, name);
    status(`已发起下载:${name}${adjusted ? '(已调整时间轴)' : ''}`);
  }
  function formatLines(text) {
    const lines = text.split('\n').map(x => x.trim()).filter(Boolean);
    const zh = [], en = [];
    for (const line of lines) {
      if (/[\u3400-\u9fff]/.test(line)) zh.push(line);
      else en.push(line);
    }
    return [zh.join('\n'), en.join('\n')];
  }
  function cueAt(ms) {
    // 允许多个重叠条目;最大返回3条,防止屏幕被异常字幕覆盖。
    const cues = state.cues;
    let lo = 0, hi = cues.length;
    while (lo < hi) { const mid = (lo + hi) >> 1; if (cues[mid].start <= ms) lo = mid + 1; else hi = mid; }
    const hits = [];
    for (let i = lo - 1; i >= 0 && i >= lo - 16; i--) {
      if (cues[i].end >= ms) hits.unshift(cues[i].text);
      if (hits.length >= 3) break;
    }
    return hits.join('\n');
  }
  function fullscreenHost() {
    const fs = document.fullscreenElement || document.webkitFullscreenElement;
    return fs && fs !== state.video ? fs : document.body;
  }
  function createOverlay() {
    if (state.overlay) return;
    const el = document.createElement('div');
    el.id = `${K}-overlay`;
    el.innerHTML = `<div id="${K}-zh"></div><div id="${K}-en"></div><div id="${K}-draglabel">拖动调整字幕位置</div>`;
    (document.body || document.documentElement).appendChild(el);
    state.overlay = el;
    el.addEventListener('pointerdown', ev => {
      if (!state.editing || !state.video) return;
      ev.preventDefault(); ev.stopPropagation();
      const startY = ev.clientY, startBottom = settings.bottom;
      el.setPointerCapture(ev.pointerId);
      function move(e) {
        const r = state.video.getBoundingClientRect();
        settings.bottom = clamp(startBottom + (startY - e.clientY) * 100 / Math.max(1, r.height), 0, 65);
        $('bottom').value = String(settings.bottom); $('bottomValue').textContent = `${Math.round(settings.bottom)}%`;
        saveSettings(); placeOverlay();
      }
      function end() { el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', end); el.removeEventListener('pointercancel', end); }
      el.addEventListener('pointermove', move); el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end);
    });
  }
  function placeOverlay() {
    const el = state.overlay, v = state.video;
    if (!el || !v || !el.isConnected) return;
    const host = fullscreenHost();
    if (el.parentNode !== host) host.appendChild(el);
    const rect = v.getBoundingClientRect();
    const fixed = getComputedStyle(el).position === 'fixed';
    if (!fixed) el.style.position = 'fixed';
    el.style.left = `${rect.left}px`;
    el.style.top = `${rect.top}px`;
    el.style.width = `${rect.width}px`;
    el.style.height = `${rect.height}px`;
    el.style.paddingBottom = `${Math.round(rect.height * settings.bottom / 100)}px`;
    el.classList.toggle('editing', state.editing);
    el.style.setProperty('--zh-size', `${settings.zhSize}px`);
    el.style.setProperty('--en-size', `${settings.enSize}px`);
    el.style.setProperty('--back-opacity', String(settings.opacity / 100));
    el.style.setProperty('--shadow', settings.outline ? '0 2px 4px #000, 0 0 8px #000' : 'none');
  }
  function updateCue() {
    if (!state.overlay) return;
    const value = state.video && state.enabled && state.active ? cueAt((state.video.currentTime - state.offset) * 1000) : '';
    const [zh, en] = formatLines(value);
    $('zh').textContent = settings.inverted ? en : zh;
    $('en').textContent = settings.inverted ? zh : en;
    $('zh').style.fontSize = `${settings.inverted ? settings.enSize : settings.zhSize}px`;
    $('en').style.fontSize = `${settings.inverted ? settings.zhSize : settings.enSize}px`;
    state.overlay.classList.toggle('has-cue', !!value);
  }
  function renderResults() {
    const root = $('results'); if (!root) return;
    root.replaceChildren();
    if (!state.items.length) { root.textContent = '没有符合当前匹配阈值的候选字幕'; return; }
    state.items.forEach((item, i) => {
      const row = document.createElement('div');
      row.className = 'sh-result';
      const info = document.createElement('div'); info.className = 'sh-result-text';
      const title = document.createElement('div'); title.textContent = item.title; title.title = item.title;
      const note = document.createElement('small'); note.textContent = `${item.source} · 名称相似度 ${Math.round(item.match * 100)}%${item === state.active ? ' · 正在预览' : ''}`;
      info.append(title, note);
      const btn = document.createElement('button'); btn.textContent = item === state.active ? '已试播' : '试播';
      btn.addEventListener('click', () => preview(item));
      row.append(info, btn); root.append(row);
    });
  }
  function addPanel() {
    const panel = document.createElement('aside'); panel.id = `${K}-panel`;
    panel.innerHTML = `
      <header id="${K}-header"><strong>115字幕助手 2.0</strong><div><button id="${K}-fold" title="收起为小图标">−</button><button id="${K}-close" title="收起面板,字幕继续播放">×</button></div></header>
      <main id="${K}-body">
        <div class="sh-label">影片名 / 搜索词(识别不准可手动修改)</div>
        <div class="sh-row"><input id="${K}-keyword" placeholder="输入电影名、剧名或番号"><button id="${K}-search">搜索</button></div>
        <div id="${K}-status" class="sh-status"></div>
        <div id="${K}-results" class="sh-results"></div>
        <section class="sh-controls">
          <div class="sh-row sh-between"><label><input type="checkbox" id="${K}-enabled" checked>显示外挂字幕</label><label><input type="checkbox" id="${K}-drag">拖动字幕</label></div>
          <label class="sh-label">中文字号 <output id="${K}-zhSizeValue">${settings.zhSize}px</output></label>
          <input type="range" id="${K}-zhSize" min="16" max="64" step="1" value="${settings.zhSize}">
          <label class="sh-label">英文字号 <output id="${K}-enSizeValue">${settings.enSize}px</output></label>
          <input type="range" id="${K}-enSize" min="14" max="52" step="1" value="${settings.enSize}">
          <label class="sh-label">距离画面底部 <output id="${K}-bottomValue">${Math.round(settings.bottom)}%</output></label>
          <input type="range" id="${K}-bottom" min="0" max="65" step="1" value="${settings.bottom}">
          <label class="sh-label">字幕底色透明度 <output id="${K}-opacityValue">${settings.opacity}%</output></label>
          <input type="range" id="${K}-opacity" min="0" max="80" step="5" value="${settings.opacity}">
          <div class="sh-row sh-between"><label><input type="checkbox" id="${K}-outline" ${settings.outline ? 'checked' : ''}>描边阴影</label><label><input type="checkbox" id="${K}-inverted" ${settings.inverted ? 'checked' : ''}>交换中英位置</label></div>
          <div class="sh-row sh-between"><span>字幕时间偏移(秒)</span><input type="number" id="${K}-offset" step="0.5" value="0.0"></div>
          <div class="sh-row sh-between"><button id="${K}-earlier">提前0.5秒</button><button id="${K}-later">延后0.5秒</button><button id="${K}-reset">重置样式</button></div>
          <label class="sh-label"><input type="checkbox" id="${K}-adjusted">下载校时版本(保留原字幕格式)</label>
          <button id="${K}-download" class="sh-download">下载当前预览字幕</button>
        </section>
        <small class="sh-disclaimer">试播仅读取字幕到内存;名称相似度不代表字幕一定正确。字幕拖动时不响应视频点击。ASS特效仅做文本预览。</small>
      </main>`;
    document.body.append(panel); state.panel = panel;
    $('status').textContent = state.message;
    $('search').addEventListener('click', search);
    $('keyword').addEventListener('keydown', e => { if (e.key === 'Enter') search(); });
    $('close').addEventListener('click', () => setPanelOpen(false));
    $('fold').addEventListener('click', () => setPanelOpen(false));
    $('enabled').addEventListener('change', e => { state.enabled = e.target.checked; updateCue(); });
    $('drag').addEventListener('change', e => { state.editing = e.target.checked; placeOverlay(); });
    for (const [name, suffix] of [['zhSize', 'px'], ['enSize', 'px'], ['bottom', '%'], ['opacity', '%']]) {
      $(name).addEventListener('input', e => {
        settings[name] = Number(e.target.value);
        $(`${name}Value`).textContent = `${settings[name]}${suffix}`;
        saveSettings(); placeOverlay(); updateCue();
      });
    }
    $('outline').addEventListener('change', e => { settings.outline = e.target.checked; saveSettings(); placeOverlay(); });
    $('inverted').addEventListener('change', e => { settings.inverted = e.target.checked; saveSettings(); updateCue(); });
    $('offset').addEventListener('input', e => { state.offset = clamp(Number(e.target.value) || 0, -3600, 3600); updateCue(); });
    $('earlier').addEventListener('click', () => adjustOffset(-0.5));
    $('later').addEventListener('click', () => adjustOffset(0.5));
    $('reset').addEventListener('click', () => {
      Object.assign(settings, DEFAULTS); saveSettings();
      for (const key of ['zhSize', 'enSize', 'bottom', 'opacity']) { $(key).value = settings[key]; $(`${key}Value`).textContent = `${settings[key]}${key === 'bottom' || key === 'opacity' ? '%' : 'px'}`; }
      $('outline').checked = settings.outline; $('inverted').checked = settings.inverted;
      placeOverlay(); updateCue();
    });
    $('download').addEventListener('click', download);
  }

  // 面板永远保留一个可重新打开的入口。折叠、关闭都不影响正在试播的字幕。
  function viewportSize() {
    return { w: document.documentElement.clientWidth || innerWidth, h: document.documentElement.clientHeight || innerHeight };
  }
  function moveIntoView(el, x, y) {
    const { w, h } = viewportSize();
    const width = el.offsetWidth || 44, height = el.offsetHeight || 44;
    el.style.left = `${clamp(x, 0, Math.max(0, w - width))}px`;
    el.style.top = `${clamp(y, 0, Math.max(0, h - height))}px`;
    el.style.right = 'auto';
    el.style.bottom = 'auto';
  }
  function rememberPosition(el, name) {
    ui[name] = { x: parseFloat(el.style.left) || 0, y: parseFloat(el.style.top) || 0 };
    saveUI();
  }
  // 展开面板固定在右上角,始终保持完整可见;只有悬浮图标可拖动。
  function positionPanel() {
    const el = state.panel;
    if (!el) return;
    const { w, h } = viewportSize();
    const edge = 12;
    el.style.position = 'fixed';
    el.style.left = `${Math.max(edge, w - Math.min(390, w - 2 * edge) - edge)}px`;
    el.style.top = `${Math.min(60, Math.max(edge, h - 100))}px`;
    el.style.right = 'auto';
    el.style.bottom = 'auto';
    el.style.maxHeight = `${Math.max(80, h - Math.min(60, Math.max(edge, h - 100)) - edge)}px`;
  }
  function applySavedPosition(el, name) {
    if (name === 'panel') return positionPanel();
    const stored = ui[name];
    const { w, h } = viewportSize();
    moveIntoView(el, Number.isFinite(stored?.x) ? stored.x : Math.max(0, w - 59),
      Number.isFinite(stored?.y) ? stored.y : Math.max(0, h - 110));
  }
  function enableDrag(el, handle, name) {
    let down = null;
    handle.style.touchAction = 'none';
    handle.addEventListener('pointerdown', event => {
      if (event.button !== 0 || event.target.closest('button,input,a,select')) return;
      const rect = el.getBoundingClientRect();
      down = { id: event.pointerId, x: event.clientX, y: event.clientY, left: rect.left, top: rect.top, moved: false };
      handle.setPointerCapture(event.pointerId);
      event.preventDefault();
      event.stopPropagation();
    });
    handle.addEventListener('pointermove', event => {
      if (!down || event.pointerId !== down.id) return;
      const dx = event.clientX - down.x, dy = event.clientY - down.y;
      if (Math.abs(dx) + Math.abs(dy) > 5) down.moved = true;
      if (down.moved) moveIntoView(el, down.left + dx, down.top + dy);
    });
    const end = event => {
      if (!down || event.pointerId !== down.id) return;
      if (down.moved) {
        rememberPosition(el, name);
        if (name === 'launcher') el.dataset.justDragged = '1';
      }
      down = null;
    };
    handle.addEventListener('pointerup', end);
    handle.addEventListener('pointercancel', end);
  }
  function setPanelOpen(open) {
    ui.open = open; saveUI();
    if (!state.panel || !state.launcher) return;
    state.panel.hidden = !open;
    state.launcher.hidden = open;
    state.expanded = open;
    if (open) applySavedPosition(state.panel, 'panel');
    else applySavedPosition(state.launcher, 'launcher');
  }
  function createLauncher() {
    const launcher = document.createElement('button');
    launcher.id = `${K}-launcher`;
    launcher.type = 'button';
    launcher.title = '点击打开115字幕助手,按住拖动位置';
    launcher.textContent = '字幕';
    document.body.appendChild(launcher);
    state.launcher = launcher;
    enableDrag(launcher, launcher, 'launcher');
    launcher.addEventListener('click', event => {
      event.stopPropagation();
      if (launcher.dataset.justDragged === '1') { launcher.dataset.justDragged = ''; return; }
      setPanelOpen(true);
    });
    // 防止操作播放器时触发播放、暂停等动作。
    launcher.addEventListener('dblclick', e => e.stopPropagation());
    applySavedPosition(launcher, 'launcher');
    setPanelOpen(ui.open !== false);
  }
  function placeUI() {
    if (!state.panel || !state.launcher) return;
    const fs = document.fullscreenElement || document.webkitFullscreenElement;
    const host = fs && fs !== state.video ? fs : document.body;
    if (host && state.panel.parentNode !== host) host.appendChild(state.panel);
    if (host && state.launcher.parentNode !== host) host.appendChild(state.launcher);
    if (!state.panel.hidden) applySavedPosition(state.panel, 'panel');
    if (!state.launcher.hidden) applySavedPosition(state.launcher, 'launcher');
  }
  function adjustOffset(delta) {
    state.offset = Math.round((state.offset + delta) * 10) / 10;
    $('offset').value = state.offset.toFixed(1);
    updateCue();
  }
  function updateLifeCycle() {
    const newVideo = findVideo();
    const newPick = currentPickCode();
    const newPath = location.pathname + location.search;
    // 播放器换源/seek可能改变currentSrc,不能把它作为切换影片的证据。
    // 同一pickcode下重建video元素时,保留当前字幕。
    const pickChanged = Boolean(state.lastPickCode && newPick && state.lastPickCode !== newPick);
    const pageChanged = state.lastPagePath !== newPath;
    const fileChanged = pickChanged || (pageChanged && (!state.lastPickCode || !newPick));
    if (fileChanged) {
      state.active = null; state.cues = []; state.activeRaw = null; state.offset = 0; state.subtitlesToken++; state.fileName = '';
      if ($('offset')) $('offset').value = '0.0';
      status('检测到视频变化,已清除上一视频字幕'); renderResults();
    }
    state.video = newVideo;
    state.lastPickCode = newPick;
    state.lastPagePath = newPath;
    if (state.video && !state.fileName) {
      const title = filenameFromPage();
      if (title) { state.fileName = title; if (!$('keyword').value.trim()) $('keyword').value = codeOf(title) || normalize(title); }
    }
    if (state.overlay) { placeOverlay(); updateCue(); }
  }
  GM_addStyle(`
    #${K}-panel[hidden],#${K}-launcher[hidden]{display:none!important}
    #${K}-launcher{position:fixed;z-index:2147483647;width:44px;height:44px;border:1px solid #a0a6b5;border-radius:50%;background:#252b36;color:#fff;box-shadow:0 3px 13px #0008;cursor:grab;touch-action:none;user-select:none;font:600 12px system-ui,'Microsoft YaHei',sans-serif}
    #${K}-launcher:active{cursor:grabbing}
    #${K}-panel{position:fixed!important;z-index:2147483647;top:60px;right:15px;width:min(390px,calc(100vw - 24px));max-height:calc(100vh - 72px);overflow-y:auto;overflow-x:hidden;background:#171b22;color:#f2f4f8;border:1px solid #5b6472;border-radius:11px;box-shadow:0 12px 40px #000a;font:12px/1.5 system-ui,'Microsoft YaHei',sans-serif}
    #${K}-panel *{box-sizing:border-box}#${K}-panel header{cursor:default;user-select:none;position:sticky;top:0;z-index:2;background:#252a34;display:flex;justify-content:space-between;align-items:center;padding:9px 12px}#${K}-panel header button{margin-left:5px}#${K}-body{padding:12px}#${K}-body[hidden]{display:none!important}
    #${K}-panel button{color:#fff;background:#365fac;border:0;border-radius:6px;padding:6px 9px;cursor:pointer}#${K}-panel button:disabled{opacity:.4}
    #${K}-panel input:not([type=checkbox]):not([type=range]){min-width:0;background:#101419;border:1px solid #5b6472;color:#fff;border-radius:6px;padding:6px}#${K}-panel input[type=range]{width:100%;accent-color:#619dff}#${K}-panel input[type=checkbox]{accent-color:#619dff}
    #${K}-keyword{flex:1}#${K}-offset{width:75px}.sh-row{display:flex;gap:7px;align-items:center}.sh-between{justify-content:space-between}.sh-label{display:block;margin:7px 0 3px;color:#c8d1de}.sh-label output{float:right}.sh-status{margin:8px 0;color:#abc0df;min-height:18px}
    .sh-results{max-height:180px;overflow:auto;border-top:1px solid #ffffff22;border-bottom:1px solid #ffffff22}.sh-result{display:flex;gap:6px;align-items:center;padding:7px 0;border-bottom:1px solid #ffffff18}.sh-result-text{min-width:0;flex:1}.sh-result-text>div{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sh-result-text small{color:#9cacbf}
    .sh-controls{padding-top:6px}.sh-controls .sh-row{margin:8px 0}.sh-controls .sh-row button{flex:1}.sh-download{width:100%;margin-top:6px;background:#267451!important}.sh-disclaimer{display:block;margin-top:10px;color:#9aa7b8}
    #${K}-overlay{position:fixed;z-index:2147483646;pointer-events:none;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;box-sizing:border-box;text-align:center;padding-left:3%;padding-right:3%;color:#fff;text-shadow:var(--shadow);font-family:Arial,'Microsoft YaHei',sans-serif;white-space:pre-line;overflow:hidden}
    #${K}-overlay #${K}-zh,#${K}-overlay #${K}-en{line-height:1.36;max-width:95%;overflow-wrap:anywhere;background:rgba(0,0,0,var(--back-opacity));border-radius:4px;padding:0 4px}
    #${K}-overlay #${K}-draglabel{display:none;font-size:12px;background:#365facb8;border-radius:5px;padding:3px 7px}
    #${K}-overlay.editing{pointer-events:auto;cursor:grab;outline:2px dashed #62aaffaa;touch-action:none}#${K}-overlay.editing #${K}-draglabel{display:block}#${K}-overlay.editing:active{cursor:grabbing}
  `);
  function init() {
    if (!document.body) return setTimeout(init, 200);
    addPanel(); createLauncher(); createOverlay();
    updateLifeCycle();
    setInterval(updateLifeCycle, 250);
    document.addEventListener('fullscreenchange', () => setTimeout(() => { placeOverlay(); placeUI(); }, 50));
    document.addEventListener('webkitfullscreenchange', () => setTimeout(() => { placeOverlay(); placeUI(); }, 50));
    window.addEventListener('resize', () => { if (!state.panel.hidden) applySavedPosition(state.panel, 'panel'); if (!state.launcher.hidden) applySavedPosition(state.launcher, 'launcher'); });
    // 面板收起不改变播放器或试播状态。
  }
  init();
})();
複製代碼


评论 (0)

登录 后参与讨论。

还没有评论,来抢沙发吧。