您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
自建覆盖层显示双语:英文在上中文在下;零/负行距紧贴;独立背景;可调字号与垂直位置与行距;默认更大字号 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'); } })();