參考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();
})();
複製代碼
还没有评论,来抢沙发吧。