// ==UserScript==
// @name 特教教材国家级示范培训 · 学习辅助面板(合规版)
// @namespace http://tampermonkey.net/
// @version 1.2
// @description 面板显示运行状态与真实学习时间 / 一键开始连续播放 / 自动下一节 / 快捷键 / 本地笔记 | 不跳转不加速不伪造上报
// @author Helper
// @match https://t.pep.com.cn/tj2025/*
// @match https://t.pep.com.cn/tj2024/*
// @match https://t.pep.com.cn/tj2022/*
// @match https://wp.pep.com.cn/*
// @run-at document-idle
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ===================== 配置 =====================
const CONFIG = {
videoSelector: 'video',
seekStep: 10,
volumeStep: 0.1,
noteStorageKey: 'tj_pep_notes',
statsStorageKey: 'tj_study_stats',
autoNext: {
enabled: false,
countdown: 3,
selectors: [
'a[class*="next"]', 'button[class*="next"]',
'[class*="next-btn"]', '[class*="nextBtn"]',
'.next', '.btn-next'
],
texts: ['下一节', '下一个', '下一讲', '下一课', '下一集', '下一章']
}
};
CONFIG.autoNext.enabled = localStorage.getItem('tj_auto_next') === '1';
// ===================== 工具函数 =====================
const $ = (sel, root = document) => root.querySelector(sel);
const pad = n => String(n).padStart(2, '0');
function fmtTime(sec) {
if (sec == null || isNaN(sec) || !isFinite(sec)) return '--:--';
const s = Math.floor(sec % 60);
const m = Math.floor((sec / 60) % 60);
const h = Math.floor(sec / 3600);
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
}
function fmtDur(sec) {
sec = Math.max(0, Math.floor(sec || 0));
return `${pad(Math.floor(sec / 3600))}:${pad(Math.floor(sec % 3600 / 60))}:${pad(sec % 60)}`;
}
function log(msg) { console.log('[特教培训辅助]', msg); }
function escapeHtml(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
function isVisible(el) {
if (!el || el.disabled) return false;
const r = el.getBoundingClientRect();
const st = getComputedStyle(el);
return r.width > 0 && r.height > 0 && st.visibility !== 'hidden' &&
st.display !== 'none' && st.opacity !== '0';
}
function waitForVideo(timeout = 30000) {
return new Promise((resolve, reject) => {
const start = Date.now();
const check = () => {
const v = $(CONFIG.videoSelector);
if (v) return resolve(v);
if (Date.now() - start > timeout) return reject(new Error('未找到视频元素'));
setTimeout(check, 400);
};
check();
});
}
function showTip(msg, ms = 2600) {
const el = document.createElement('div');
el.className = 'tj-tip';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), ms);
}
// ===================== 真实学习时间统计 =====================
const stats = loadStats();
let sessionSeconds = 0; // 本次会话(页面加载后)真实播放累计
let lastTick = performance.now();
let lastSave = 0;
let studyRunning = false; // 一键学习是否处于开启状态(仅用于界面状态)
function todayKey() {
const d = new Date();
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function pageKey() { return location.pathname + location.search; }
function loadStats() {
try {
const s = JSON.parse(localStorage.getItem(CONFIG.statsStorageKey) || '{}');
return { total: s.total || 0, daily: s.daily || {}, pages: s.pages || {} };
} catch {
return { total: 0, daily: {}, pages: {} };
}
}
function saveStats() {
try { localStorage.setItem(CONFIG.statsStorageKey, JSON.stringify(stats)); } catch {}
}
// 每秒结算一次,只在视频真实播放时累加
function tickStats(video) {
const now = performance.now();
const delta = (now - lastTick) / 1000;
lastTick = now;
if (!video) return;
const playing = !video.paused && !video.ended && video.readyState >= 2;
if (!playing) return;
// 防止标签页休眠导致的超大跳变
if (delta <= 0 || delta > 5) return;
sessionSeconds += delta;
stats.total += delta;
const dk = todayKey();
stats.daily[dk] = (stats.daily[dk] || 0) + delta;
const pk = pageKey();
stats.pages[pk] = (stats.pages[pk] || 0) + delta;
if (now - lastSave > 5000) { saveStats(); lastSave = now; }
}
setInterval(() => tickStats($(CONFIG.videoSelector)), 1000);
window.addEventListener('beforeunload', saveStats);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') saveStats();
});
// ===================== 快捷键 =====================
function bindShortcuts(video) {
if (video.__tjKeysBound) return;
video.__tjKeysBound = true;
document.addEventListener('keydown', function (e) {
const tag = (e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
switch (e.key) {
case ' ':
e.preventDefault();
video.paused ? video.play().catch(() => {}) : video.pause();
break;
case 'ArrowLeft':
e.preventDefault();
video.currentTime = Math.max(0, video.currentTime - CONFIG.seekStep);
break;
case 'ArrowRight':
e.preventDefault();
video.currentTime = Math.min(video.duration || 0, video.currentTime + CONFIG.seekStep);
break;
case 'ArrowUp':
e.preventDefault();
video.volume = Math.min(1, video.volume + CONFIG.volumeStep);
break;
case 'ArrowDown':
e.preventDefault();
video.volume = Math.max(0, video.volume - CONFIG.volumeStep);
break;
case 'm': case 'M':
e.preventDefault();
video.muted = !video.muted;
break;
case 'n': case 'N':
e.preventDefault();
addNoteAtCurrentTime(video);
break;
}
});
}
// ===================== 笔记 =====================
function loadNotes() {
try { return JSON.parse(localStorage.getItem(CONFIG.noteStorageKey) || '[]'); }
catch { return []; }
}
function saveNotes(notes) {
localStorage.setItem(CONFIG.noteStorageKey, JSON.stringify(notes));
}
function addNoteAtCurrentTime(video) {
const t = video.currentTime;
const text = prompt(`在 ${fmtTime(t)} 处记录笔记:`, '');
if (text == null) return;
const trimmed = text.trim();
if (!trimmed) return;
const notes = loadNotes();
notes.push({ time: t, timeLabel: fmtTime(t), text: trimmed, page: location.href, createdAt: new Date().toLocaleString() });
saveNotes(notes);
renderNotes();
}
function renderNotes() {
const list = document.getElementById('noteList');
if (!list) return;
const notes = loadNotes().filter(n => n.page === location.href).sort((a, b) => a.time - b.time);
if (!notes.length) {
list.innerHTML = '
暂无笔记,播放时按 N 添加
';
return;
}
list.innerHTML = notes.map(n => `
${n.timeLabel}
${escapeHtml(n.text)}
`).join('');
list.querySelectorAll('.note-time').forEach(el => {
el.onclick = () => {
const v = $(CONFIG.videoSelector);
if (v) v.currentTime = parseFloat(el.dataset.t);
};
});
}
// ===================== 自动下一节 =====================
let nextTimer = null;
let nextTarget = null;
function findNextButton() {
const panel = document.getElementById('tj-helper-panel');
for (const sel of CONFIG.autoNext.selectors) {
for (const el of document.querySelectorAll(sel)) {
if (panel && panel.contains(el)) continue;
if (isVisible(el)) return el;
}
}
const candidates = document.querySelectorAll('a, button, [role="button"], [class*="btn"], [class*="next"]');
for (const el of candidates) {
if (panel && panel.contains(el)) continue;
const t = (el.textContent || '').replace(/\s+/g, '').trim();
if (!t || t.length > 12) continue;
if (CONFIG.autoNext.texts.some(x => t === x || t.includes(x)) && isVisible(el)) return el;
}
return null;
}
function cancelPendingNext() {
if (nextTimer) { clearInterval(nextTimer); nextTimer = null; }
const toast = document.getElementById('tj-next-toast');
if (toast) toast.remove();
nextTarget = null;
}
function triggerAutoNext() {
if (!CONFIG.autoNext.enabled) return;
if (nextTimer) return;
const btn = findNextButton();
if (!btn) {
log('视频结束,未找到“下一节”按钮');
showTip('视频已结束,未找到“下一节”按钮,请手动切换');
return;
}
nextTarget = btn;
let remain = CONFIG.autoNext.countdown;
const toast = document.createElement('div');
toast.id = 'tj-next-toast';
toast.innerHTML = `⏭ 视频已结束,${remain} 秒后自动播放下一节`;
document.body.appendChild(toast);
nextTimer = setInterval(() => {
remain--;
if (remain <= 0) {
clearInterval(nextTimer); nextTimer = null;
toast.remove();
const target = nextTarget; nextTarget = null;
log('自动点击下一节');
try { target.click(); } catch (e) { log('点击失败:' + e.message); }
} else {
const cnt = toast.querySelector('.cnt');
if (cnt) cnt.textContent = remain;
}
}, 1000);
toast.querySelector('.cancel').onclick = () => { cancelPendingNext(); log('已取消自动跳转'); };
}
function bindEndedHandler(video) {
if (video.__tjEndedBound) return;
video.__tjEndedBound = true;
video.addEventListener('ended', () => { log('视频自然播放结束'); triggerAutoNext(); });
video.addEventListener('loadstart', cancelPendingNext);
}
// ===================== 一键学习 / 停止 =====================
function syncAutoNextUI() {
const t = document.getElementById('autoNextToggle');
if (t) t.checked = CONFIG.autoNext.enabled;
}
async function startOneClickStudy() {
let video;
try { video = await waitForVideo(15000); }
catch { showTip('未找到视频元素,请确认已进入播放页'); return; }
CONFIG.autoNext.enabled = true;
syncAutoNextUI();
studyRunning = true;
if (video.ended) { // 若已播完,直接进入下一节
triggerAutoNext();
return;
}
if (video.paused) {
try {
await video.play();
log('一键学习:已开始播放');
} catch (e) {
showTip('浏览器拦截了自动播放,请先点击一次视频区域,再点“一键学习”');
return;
}
}
}
function stopStudy() {
CONFIG.autoNext.enabled = false;
syncAutoNextUI();
cancelPendingNext();
studyRunning = false;
showTip('已停止自动连播(当前视频不会暂停)');
}
// ===================== 面板刷新 =====================
let rafId = null;
function refreshPanel(video) {
const curEl = document.getElementById('curTime');
const durEl = document.getElementById('durTime');
const rateEl = document.getElementById('playRate');
const fillEl = document.getElementById('progressFill');
const stateEl = document.getElementById('runState');
const sessEl = document.getElementById('sessionTime');
const todayEl = document.getElementById('todayTime');
const pageEl = document.getElementById('pageTime');
if (video) {
const cur = video.currentTime, dur = video.duration;
if (curEl) curEl.textContent = fmtTime(cur);
if (durEl) durEl.textContent = fmtTime(dur);
if (rateEl) rateEl.textContent = (video.playbackRate || 1) + 'x';
if (fillEl) {
const p = dur && isFinite(dur) ? Math.min(100, cur / dur * 100) : 0;
fillEl.style.width = p.toFixed(1) + '%';
}
if (stateEl) {
let s = '未开始', c = '#8899bb';
if (video.ended) { s = '本节已结束'; c = '#ffa500'; }
else if (video.paused) { s = '已暂停'; c = '#ffd700'; }
else if (video.readyState >= 2) { s = studyRunning ? '🔴 学习中' : '播放中'; c = '#8f8'; }
else { s = '加载中…'; c = '#8899bb'; }
stateEl.textContent = s;
stateEl.style.color = c;
}
}
if (sessEl) sessEl.textContent = fmtDur(sessionSeconds);
if (todayEl) todayEl.textContent = fmtDur(stats.daily[todayKey()] || 0);
if (pageEl) pageEl.textContent = fmtDur(stats.pages[pageKey()] || 0);
rafId = requestAnimationFrame(() => refreshPanel($(CONFIG.videoSelector)));
}
// ===================== 创建面板 =====================
function createPanel(video) {
GM_addStyle(`
#tj-helper-panel {
position: fixed; bottom: 24px; right: 24px; width: 320px;
background: linear-gradient(160deg, rgba(18,24,42,0.96), rgba(10,15,28,0.98));
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255,215,0,0.12); border-radius: 16px;
padding: 16px; z-index: 999999;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
color: #e2e8f0; box-shadow: 0 16px 48px rgba(0,0,0,0.65); user-select: none;
}
#tj-helper-panel .hdr {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px; cursor: move; padding-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.07);
}
#tj-helper-panel .hdr .t {
font-size: 14px; font-weight: 700;
background: linear-gradient(135deg,#ffd700,#f5a623);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
#tj-helper-panel .hdr .tag {
font-size: 10px; color: #ffd700; background: rgba(255,215,0,0.1);
padding: 2px 8px; border-radius: 10px;
}
#tj-helper-panel .actions { display: flex; gap: 8px; margin-bottom: 12px; }
#tj-helper-panel .btn-primary {
flex: 1; padding: 11px 0; border: none; border-radius: 10px;
background: linear-gradient(135deg,#f5af19,#f12711); color: #fff;
font-size: 14px; font-weight: 700; cursor: pointer;
transition: transform .15s, box-shadow .15s;
box-shadow: 0 4px 16px rgba(241,39,17,0.25);
}
#tj-helper-panel .btn-primary:hover { transform: translateY(-1px); }
#tj-helper-panel .btn-primary:active { transform: scale(.98); }
#tj-helper-panel .btn-stop {
flex: 0 0 90px; padding: 11px 0; border: 1px solid rgba(255,120,120,0.25);
border-radius: 10px; background: rgba(255,120,120,0.08);
color: #ff9999; font-size: 13px; cursor: pointer;
}
#tj-helper-panel .btn-stop:hover { background: rgba(255,120,120,0.16); }
#tj-helper-panel .sec-title {
font-size: 10px; color: #667799; letter-spacing: 1px;
margin: 10px 0 6px; text-transform: uppercase;
}
#tj-helper-panel .row {
display: flex; justify-content: space-between; align-items: center;
font-size: 12px; color: #8899bb; padding: 4px 0;
}
#tj-helper-panel .row .v { color: #e2e8f0; font-weight: 600; font-variant-numeric: tabular-nums; }
#tj-helper-panel .time-big {
text-align: center; font-size: 28px; font-weight: 700; color: #ffd700;
font-variant-numeric: tabular-nums; letter-spacing: 1px; line-height: 1.2;
}
#tj-helper-panel .time-lbl {
text-align: center; font-size: 10px; color: #667799; margin-bottom: 6px;
}
#tj-helper-panel .bar {
height: 5px; background: rgba(255,255,255,0.08); border-radius: 3px;
overflow: hidden; margin: 4px 0 8px;
}
#tj-helper-panel .bar .fill {
height: 100%; width: 0%; border-radius: 3px;
background: linear-gradient(90deg,#ffd700,#f5a623); transition: width .3s;
}
#tj-helper-panel .next-row {
display: flex; align-items: center; justify-content: space-between;
background: rgba(0,0,0,0.22); border-radius: 10px;
padding: 8px 12px; margin: 8px 0;
}
#tj-helper-panel .next-row .lbl { font-size: 12px; color: #c8d6e5; }
#tj-helper-panel .next-row .sub { font-size: 10px; color: #556677; display: block; margin-top: 2px; }
#tj-helper-panel .switch { position: relative; display: inline-block; width: 38px; height: 20px; flex: none; }
#tj-helper-panel .switch input { opacity: 0; width: 0; height: 0; }
#tj-helper-panel .slider {
position: absolute; cursor: pointer; inset: 0; background: #2a3a5a;
border-radius: 20px; transition: .2s;
}
#tj-helper-panel .slider::before {
content: ''; position: absolute; width: 14px; height: 14px; left: 3px; top: 3px;
background: #fff; border-radius: 50%; transition: .2s;
}
#tj-helper-panel .switch input:checked + .slider { background: #f5a623; }
#tj-helper-panel .switch input:checked + .slider::before { transform: translateX(18px); }
#tj-helper-panel .hint {
font-size: 10px; color: #556677; text-align: center; line-height: 1.7; margin: 8px 0;
}
#tj-helper-panel .note-hdr {
display: flex; justify-content: space-between; align-items: center;
font-size: 11px; color: #88ddff; margin-bottom: 4px;
}
#noteList { max-height: 120px; overflow-y: auto; }
#noteList::-webkit-scrollbar { width: 4px; }
#noteList::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; }
#tj-next-toast {
position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%);
background: rgba(20,28,50,0.96); border: 1px solid rgba(255,215,0,0.25);
color: #e2e8f0; padding: 12px 20px; border-radius: 14px;
font-family: system-ui; font-size: 14px; z-index: 1000000;
display: flex; align-items: center; gap: 14px; box-shadow: 0 12px 36px rgba(0,0,0,0.6);
}
#tj-next-toast .cnt { color: #ffd700; font-size: 16px; }
#tj-next-toast .cancel {
background: rgba(255,255,255,0.08); border: none; color: #c8d6e5;
padding: 6px 14px; border-radius: 8px; cursor: pointer; font-size: 13px;
}
.tj-tip {
position: fixed; left: 50%; bottom: 100px; transform: translateX(-50%);
background: rgba(20,28,50,0.96); border: 1px solid rgba(255,255,255,0.12);
color: #e2e8f0; padding: 10px 18px; border-radius: 10px;
font-family: system-ui; font-size: 13px; z-index: 1000000;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
`);
const panel = document.createElement('div');
panel.id = 'tj-helper-panel';
panel.innerHTML = `
📘 学习辅助
合规版 v1.2
运行情况
运行状态未开始
播放速率1x
学习时间(真实播放)
00:00:00
本次会话
今日累计00:00:00
本页累计00:00:00
当前视频
进度--:-- / --:--
空格 播放/暂停 · ←/→ 快退/快进 ${CONFIG.seekStep}s · ↑/↓ 音量 · M 静音 · N 笔记
📝 时间点笔记
清空本页
`;
document.body.appendChild(panel);
// 按钮
document.getElementById('oneClickBtn').addEventListener('click', startOneClickStudy);
document.getElementById('stopBtn').addEventListener('click', stopStudy);
// 自动下一节开关
const toggle = document.getElementById('autoNextToggle');
toggle.checked = CONFIG.autoNext.enabled;
toggle.addEventListener('change', e => {
CONFIG.autoNext.enabled = e.target.checked;
localStorage.setItem('tj_auto_next', e.target.checked ? '1' : '0');
if (!e.target.checked) { cancelPendingNext(); studyRunning = false; }
});
// 拖动
const handle = document.getElementById('tjDragHandle');
let dragging = false, sx = 0, sy = 0, ox = 0, oy = 0;
handle.addEventListener('mousedown', e => {
e.preventDefault();
const rect = panel.getBoundingClientRect();
ox = rect.left; oy = rect.top; sx = e.clientX; sy = e.clientY;
dragging = true; panel.style.transition = 'none';
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
e.preventDefault();
const nx = Math.max(0, Math.min(ox + e.clientX - sx, innerWidth - panel.offsetWidth));
const ny = Math.max(0, Math.min(oy + e.clientY - sy, innerHeight - panel.offsetHeight));
panel.style.left = nx + 'px'; panel.style.top = ny + 'px';
panel.style.right = 'auto'; panel.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => {
if (dragging) { dragging = false; panel.style.transition = ''; }
});
// 清空本页笔记
document.getElementById('clearNotesBtn').addEventListener('click', () => {
if (!confirm('确定清空当前页面的所有笔记?')) return;
saveNotes(loadNotes().filter(n => n.page !== location.href));
renderNotes();
});
renderNotes();
refreshPanel(video);
}
// ===================== 初始化 =====================
async function init() {
let video;
try {
video = await waitForVideo();
} catch {
log('未找到视频元素。如播放器不是原生 video,请调整 CONFIG.videoSelector。');
return;
}
log('视频元素已找到');
lastTick = performance.now();
bindShortcuts(video);
bindEndedHandler(video);
createPanel(video);
// SPA 切换视频时重绑
const observer = new MutationObserver(() => {
const v = $(CONFIG.videoSelector);
if (v && v !== video) {
if (rafId) cancelAnimationFrame(rafId);
cancelPendingNext();
video = v;
lastTick = performance.now();
bindShortcuts(video);
bindEndedHandler(video);
refreshPanel(video);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();