// ==UserScript==
// @name 特教教材国家级示范培训 · 学习辅助面板(合规版 v1.3)
// @namespace http://tampermonkey.net/
// @version 1.3
// @description 面板始终显示;视频懒检测绑定;真实学习时间统计;自动下一节;快捷键;本地笔记
// @author Helper
// @match https://t.pep.com.cn/*
// @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), m = Math.floor(sec / 60 % 60), 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(), st = getComputedStyle(el);
return r.width > 0 && r.height > 0 && st.visibility !== 'hidden' &&
st.display !== 'none' && st.opacity !== '0';
}
function showTip(msg, ms = 3000) {
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;
let currentVideo = null;
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() {
const now = performance.now();
const delta = (now - lastTick) / 1000;
lastTick = now;
const v = currentVideo;
if (!v || v.paused || v.ended || v.readyState < 2) return;
if (delta <= 0 || delta > 5) return;
sessionSeconds += delta;
stats.total += delta;
stats.daily[todayKey()] = (stats.daily[todayKey()] || 0) + delta;
stats.pages[pageKey()] = (stats.pages[pageKey()] || 0) + delta;
if (now - lastSave > 5000) { saveStats(); lastSave = now; }
}
setInterval(tickStats, 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;
const v = currentVideo;
if (!v) return;
switch (e.key) {
case ' ': e.preventDefault(); v.paused ? v.play().catch(() => {}) : v.pause(); break;
case 'ArrowLeft': e.preventDefault(); v.currentTime = Math.max(0, v.currentTime - CONFIG.seekStep); break;
case 'ArrowRight': e.preventDefault(); v.currentTime = Math.min(v.duration || 0, v.currentTime + CONFIG.seekStep); break;
case 'ArrowUp': e.preventDefault(); v.volume = Math.min(1, v.volume + CONFIG.volumeStep); break;
case 'ArrowDown': e.preventDefault(); v.volume = Math.max(0, v.volume - CONFIG.volumeStep); break;
case 'm': case 'M': e.preventDefault(); v.muted = !v.muted; break;
case 'n': case 'N': e.preventDefault(); addNoteAtCurrentTime(v); 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 = () => { if (currentVideo) currentVideo.currentTime = parseFloat(el.dataset.t); };
});
}
// ===================== 自动下一节 =====================
let nextTimer = null, 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;
}
}
for (const el of document.querySelectorAll('a, button, [role="button"], [class*="btn"], [class*="next"]')) {
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 || 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 t = nextTarget; nextTarget = null;
try { t.click(); } catch (e) { log('点击失败:' + e.message); }
} else {
const c = toast.querySelector('.cnt'); if (c) c.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() {
const v = currentVideo || $(CONFIG.videoSelector);
if (!v) { showTip('未检测到视频。请确认已进入播放页,或按 F12 检查 video / iframe'); return; }
CONFIG.autoNext.enabled = true; syncAutoNextUI(); studyRunning = true;
if (v.ended) { triggerAutoNext(); return; }
if (v.paused) {
try { await v.play(); log('一键学习:已开始播放'); }
catch { showTip('浏览器拦截了自动播放,请先点击一次视频区域再点“一键学习”'); return; }
}
}
function stopStudy() {
CONFIG.autoNext.enabled = false; syncAutoNextUI();
cancelPendingNext(); studyRunning = false;
showTip('已停止自动连播(当前视频不会暂停)');
}
// ===================== 视频检测与绑定 =====================
function attachVideo(video) {
if (!video || video === currentVideo) return;
currentVideo = video;
bindShortcuts(video);
bindEndedHandler(video);
lastTick = performance.now();
log('已绑定视频元素');
updateVideoStatus();
}
function findAndAttachVideo() {
const v = $(CONFIG.videoSelector);
if (v) attachVideo(v);
updateVideoStatus();
}
function updateVideoStatus() {
const el = document.getElementById('videoStatus');
if (!el) return;
if (currentVideo && document.contains(currentVideo)) {
el.textContent = '✅ 已检测';
el.style.color = '#8f8';
el.title = '已绑定视频元素';
} else {
el.textContent = '⚠️ 未检测';
el.style.color = '#ffa500';
el.title = detectHint();
}
const hintEl = document.getElementById('videoHint');
if (hintEl) hintEl.textContent = currentVideo ? '' : detectHint();
}
function detectHint() {
const iframes = [...document.querySelectorAll('iframe')].filter(f => f.src);
if (iframes.length) {
const src = iframes.map(f => f.src).slice(0, 2).join(' | ');
return `未找到 video。页面含 iframe:${src} —— 视频可能在 iframe 内,需把该域名加入 @match。`;
}
return '未找到 video 元素。播放器可能不是原生