自建覆盖层显示双语:英文在上中文在下;零/负行距紧贴;独立背景;可调字号与垂直位置与行距;默认更大字号 130%。
// ==UserScript==
// @name Coursera 双语字幕 (覆盖层: 英上中下, 可调行距, 默认大字号)
// @namespace http://tampermonkey.net/
// @version 2025.9.16
// @description 自建覆盖层显示双语:英文在上中文在下;零/负行距紧贴;独立背景;可调字号与垂直位置与行距;默认更大字号 130%。
// @author 唐无诗
// @match *://*.coursera.org/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @license MIT
// ==/UserScript==
(function() {
'use strict';
/********* 可配置默认 *********/
const PRIMARY_LANG = 'en';
const SECONDARY_LANG = 'zh-CN';
const DEFAULT_BOTTOM_PERCENT= 8; // 英文行基线(底部偏移百分比)
const DEFAULT_FONT_SIZE = 130; // 初始更大字号
const DEFAULT_LINE_GAP_EM = -0.05; // 中英文之间的默认行距 (可为负数压紧)
const MIN_LINE_GAP_EM = -0.30;
const MAX_LINE_GAP_EM = 0.40;
const LINE_GAP_STEP = 0.05;
/********************************/
let isDualEnabled = GM_getValue('isDualEnabled', true);
let fontSizePercent = GM_getValue('fontSize', DEFAULT_FONT_SIZE);
let bottomOffsetPct = GM_getValue('bottomOffsetPct', DEFAULT_BOTTOM_PERCENT);
let lineGapEm = GM_getValue('lineGapEm', DEFAULT_LINE_GAP_EM);
let overlay, linePrimary, lineSecondary;
let videoEl = null;
let tracksBound = false;
injectBaseStyles();
observePage();
function injectBaseStyles() {
GM_addStyle(`
.dual-sub-overlay {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
z-index: 9999;
font-family: "Helvetica Neue", Arial, sans-serif;
white-space: pre-line;
}
.dual-sub-line {
display: inline-block;
background: rgba(0,0,0,0.75);
color: #fff;
font-weight: 400;
line-height: 1.20; /* 略减行高,整体更紧凑 */
padding: 0.22em 0.65em 0.18em; /* 上下不对称,进一步压缩竖向空间 */
margin: 0;
border-radius: 2px;
max-width: 90vw;
text-align: center;
box-sizing: border-box;
white-space: pre-line;
}
/* 第二行与第一行的间距用变量控制,可为负值 */
.dual-sub-line + .dual-sub-line {
margin-top: var(--dual-gap, 0);
}
/* 控制条样式 */
#custom-subtitle-controls-container {
display: flex;
align-items: center;
gap: 8px;
border-left: 1px solid #e0e0e0;
margin-left: 8px;
padding-left: 16px;
flex-wrap: wrap;
}
#custom-subtitle-controls-container button {
height: 32px;
padding: 0 10px;
cursor: pointer;
}
#custom-subtitle-controls-container span.value {
min-width: 48px;
text-align: center;
font-size: 13px;
}
/* 启用双语覆盖层时隐藏原生显示 */
.dual-sub-active .vjs-text-track-display {
display: none !important;
}
`);
}
function observePage() {
const mo = new MutationObserver(() => {
const v = document.querySelector('video');
if (v && v !== videoEl) {
videoEl = v;
setupVideo();
}
injectControlsIfNeeded();
});
mo.observe(document.body, { childList: true, subtree: true });
}
function setupVideo() {
if (!videoEl) return;
tracksBound = false;
const tryBind = () => {
if (!videoEl.textTracks || videoEl.textTracks.length === 0) return;
if (tracksBound) return;
tracksBound = true;
videoEl.addEventListener('timeupdate', refreshOverlay);
for (const track of videoEl.textTracks) {
track.addEventListener('cuechange', refreshOverlay);
}
applyMode();
buildOverlay();
refreshOverlay();
};
let attempts = 0;
const interval = setInterval(() => {
attempts++;
tryBind();
if (tracksBound || attempts > 30) clearInterval(interval);
}, 500);
}
function getTrackByLang(langCode) {
if (!videoEl || !videoEl.textTracks) return null;
return Array.from(videoEl.textTracks).find(t => (t.language || '').toLowerCase() === langCode.toLowerCase());
}
function applyMode() {
if (!videoEl) return;
const pTrack = getTrackByLang(PRIMARY_LANG);
const sTrack = getTrackByLang(SECONDARY_LANG);
if (isDualEnabled) {
if (pTrack) pTrack.mode = 'hidden';
if (sTrack) sTrack.mode = 'hidden';
addDualClass(true);
} else {
if (pTrack) pTrack.mode = 'showing';
if (sTrack) sTrack.mode = 'disabled';
addDualClass(false);
if (overlay) overlay.style.display = 'none';
}
}
function addDualClass(active) {
const playerWrap = findPlayerContainer();
if (!playerWrap) return;
if (active) playerWrap.classList.add('dual-sub-active');
else playerWrap.classList.remove('dual-sub-active');
}
function findPlayerContainer() {
if (!videoEl) return null;
let node = videoEl.parentElement;
while (node && node !== document.body) {
const style = window.getComputedStyle(node);
if (/(relative|absolute|fixed)/.test(style.position)) return node;
node = node.parentElement;
}
return videoEl.parentElement;
}
function buildOverlay() {
const container = findPlayerContainer();
if (!container) return;
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'dual-sub-overlay';
linePrimary = document.createElement('div');
lineSecondary = document.createElement('div');
linePrimary.className = 'dual-sub-line primary-line';
lineSecondary.className = 'dual-sub-line secondary-line';
overlay.appendChild(linePrimary);
overlay.appendChild(lineSecondary);
container.appendChild(overlay);
}
overlay.style.display = isDualEnabled ? 'flex' : 'none';
applyFontSize();
applyBottomOffset();
applyLineGap();
}
function getActiveMergedText(track) {
if (!track || !track.activeCues || track.activeCues.length === 0) return '';
const parts = [];
for (const cue of track.activeCues) {
if (cue && cue.text) parts.push(cue.text.trim());
}
return parts.join('\n');
}
function refreshOverlay() {
if (!isDualEnabled || !videoEl) return;
const pTrack = getTrackByLang(PRIMARY_LANG);
const sTrack = getTrackByLang(SECONDARY_LANG);
if (!overlay) buildOverlay();
const pText = getActiveMergedText(pTrack);
const sText = getActiveMergedText(sTrack);
if (pText) {
linePrimary.style.display = 'inline-block';
linePrimary.textContent = pText;
if (sText) {
lineSecondary.style.display = 'inline-block';
lineSecondary.textContent = sText;
} else {
lineSecondary.style.display = 'none';
lineSecondary.textContent = '';
}
} else if (sText) {
linePrimary.style.display = 'inline-block';
linePrimary.textContent = sText;
lineSecondary.style.display = 'none';
lineSecondary.textContent = '';
} else {
linePrimary.style.display = 'none';
lineSecondary.style.display = 'none';
}
}
function injectControlsIfNeeded() {
if (document.getElementById('custom-subtitle-controls-container')) return;
const coachBtn = document.querySelector('div[data-testid="coach-chat-launcher-container"]');
if (!coachBtn) return;
const host = coachBtn.parentElement?.parentElement?.parentElement;
if (!host) return;
const bar = document.createElement('div');
bar.id = 'custom-subtitle-controls-container';
const btnDual = document.createElement('button');
btnDual.textContent = isDualEnabled ? '双语: 开' : '双语: 关';
btnDual.onclick = () => {
isDualEnabled = !isDualEnabled;
GM_setValue('isDualEnabled', isDualEnabled);
btnDual.textContent = isDualEnabled ? '双语: 开' : '双语: 关';
applyMode();
buildOverlay();
refreshOverlay();
};
// 字号
const btnMinus = document.createElement('button'); btnMinus.textContent = '字号 -';
const sizeVal = document.createElement('span'); sizeVal.className='value'; sizeVal.textContent = fontSizePercent + '%';
const btnPlus = document.createElement('button'); btnPlus.textContent = '字号 +';
btnMinus.onclick = () => {
if (fontSizePercent > 50) {
fontSizePercent -= 10;
GM_setValue('fontSize', fontSizePercent);
sizeVal.textContent = fontSizePercent + '%';
applyFontSize();
}
};
btnPlus.onclick = () => {
fontSizePercent += 10;
GM_setValue('fontSize', fontSizePercent);
sizeVal.textContent = fontSizePercent + '%';
applyFontSize();
};
// 垂直位置
const btnPosDown = document.createElement('button'); btnPosDown.textContent = '位置 ↓';
const posVal = document.createElement('span'); posVal.className='value'; posVal.textContent = bottomOffsetPct + '%';
const btnPosUp = document.createElement('button'); btnPosUp.textContent = '位置 ↑';
btnPosDown.onclick = () => {
bottomOffsetPct = Math.max(0, bottomOffsetPct - 1);
GM_setValue('bottomOffsetPct', bottomOffsetPct);
posVal.textContent = bottomOffsetPct + '%';
applyBottomOffset();
};
btnPosUp.onclick = () => {
bottomOffsetPct = Math.min(25, bottomOffsetPct + 1);
GM_setValue('bottomOffsetPct', bottomOffsetPct);
posVal.textContent = bottomOffsetPct + '%';
applyBottomOffset();
};
// 行距(中英文之间距离)
const btnGapMinus = document.createElement('button'); btnGapMinus.textContent = '行距 -';
const gapVal = document.createElement('span'); gapVal.className='value'; gapVal.textContent = lineGapEm.toFixed(2);
const btnGapPlus = document.createElement('button'); btnGapPlus.textContent = '行距 +';
btnGapMinus.onclick = () => {
lineGapEm = Math.max(MIN_LINE_GAP_EM, +(lineGapEm - LINE_GAP_STEP).toFixed(2));
GM_setValue('lineGapEm', lineGapEm);
gapVal.textContent = lineGapEm.toFixed(2);
applyLineGap();
};
btnGapPlus.onclick = () => {
lineGapEm = Math.min(MAX_LINE_GAP_EM, +(lineGapEm + LINE_GAP_STEP).toFixed(2));
GM_setValue('lineGapEm', lineGapEm);
gapVal.textContent = lineGapEm.toFixed(2);
applyLineGap();
};
bar.appendChild(btnDual);
bar.appendChild(btnMinus);
bar.appendChild(sizeVal);
bar.appendChild(btnPlus);
bar.appendChild(btnPosDown);
bar.appendChild(posVal);
bar.appendChild(btnPosUp);
bar.appendChild(btnGapMinus);
bar.appendChild(gapVal);
bar.appendChild(btnGapPlus);
host.appendChild(bar);
}
function applyFontSize() {
if (!overlay) return;
overlay.style.fontSize = fontSizePercent + '%';
}
function applyBottomOffset() {
if (!overlay) return;
overlay.style.bottom = bottomOffsetPct + '%';
}
function applyLineGap() {
if (!overlay) return;
overlay.style.setProperty('--dual-gap', lineGapEm + 'em');
}
})();