Keep normal playback and progress saving active across tab switches

This commit is contained in:
2026-10-06 02:10:13 +08:00
parent b8d830d231
commit 08c9e0aa4d
6 changed files with 161 additions and 28 deletions
+57 -16
View File
@@ -1,8 +1,8 @@
// ==UserScript==
// @name 学员中心 · 2倍速学习助手
// @namespace local.icampus.study
// @version 1.1.1
// @description 2倍速顺序播放、测验自动作答、按逐题反馈记忆答案并重试。
// @version 1.2.0
// @description 支持切换标签页继续2倍速播放与保存进度,自动测验及错题重试。
// @match https://icampus.yunyinsh.com/course/*
// @run-at document-start
// @grant none
@@ -100,6 +100,10 @@
const BANK_KEY = 'icampus.study.v1.answers';
const SNAPSHOT_KEY = 'icampus.study.v1.lastExam';
const RESUME_KEY = 'icampus.study.v1.recovery';
const BACKGROUND_KEY = 'icampus.study.v1.background';
let backgroundEnabled = true;
try { backgroundEnabled = localStorage.getItem(BACKGROUND_KEY) !== 'off'; } catch {}
let foregroundGraceUntil = 0;
let mediaLoad = null;
let recoveryUsed = new Set();
let bank;
@@ -143,13 +147,39 @@
}
function show(message) { if (status !== message) { status = message; log(message); } render(); }
function readLease() { try { return JSON.parse(localStorage.getItem(LEASE_KEY) || 'null'); } catch { return null; } }
const isHidden = () => document.visibilityState === 'hidden';
const backgroundActive = () => backgroundEnabled && isHidden();
const waitLimit = normal => backgroundActive() ? Math.max(normal,180000) : normal;
function holdLease() {
if (!leaseAvailable(readLease(), owner, Date.now())) return false;
try { localStorage.setItem(LEASE_KEY, JSON.stringify({owner, until: Date.now() + 12000})); }
try { localStorage.setItem(LEASE_KEY, JSON.stringify({owner, until: Date.now() + waitLimit(12000)})); }
catch { return false; }
return readLease()?.owner === owner;
}
function releaseLease() { try { if (readLease()?.owner === owner) localStorage.removeItem(LEASE_KEY); } catch {} }
function restoreVisibilityHandling() {
if (isHidden()) document.dispatchEvent(new Event('visibilitychange'));
}
function setBackgroundEnabled(enabled) {
backgroundEnabled = !!enabled;
try { localStorage.setItem(BACKGROUND_KEY,backgroundEnabled?'on':'off'); } catch {}
if(!backgroundEnabled) restoreVisibilityHandling();
log(backgroundEnabled?'已开启切标签页继续播放。':'已关闭后台模式,恢复网页的标签页切换行为。');
render();
}
function onVisibilityChange(event) {
if(!isHidden()) {
foregroundGraceUntil=Date.now()+20000;
render(); return; // Let the website restart its normal timer on return.
}
if(!running || !backgroundEnabled || !route(location.hash).id) return;
if(!holdLease()) { stop('其他学习页接管了助手,本页已暂停。'); return; }
// The course component's hidden handler stops its heartbeat timer.
// Keep the existing timer alive; leave true visibility and all requests unchanged.
event.stopImmediatePropagation();
log('已切到后台,继续正常播放并等待平台保存。'); render();
}
document.addEventListener('visibilitychange',onVisibilityChange,true);
function stop(message = '已暂停;点击开始可继续。') {
running = false; ++runNumber;
const video = active?.video;
@@ -157,6 +187,7 @@
mediaLoad = null;
try { sessionStorage.removeItem(RESUME_KEY); } catch {}
if (video && !video.paused) video.pause();
restoreVisibilityHandling();
releaseLease(); show(message);
}
function safeCourse(c) {
@@ -334,7 +365,7 @@
}
function quizWait(message, timeout = 25000) {
show(message);
if (Date.now() - quiz.since > timeout) stop('测验页面未在预期时间内响应。已保留答案记忆,请检查网页后继续。');
if (Date.now() - quiz.since > waitLimit(timeout)) stop('测验页面未在预期时间内响应。已保留答案记忆,请检查网页后继续。');
}
function tickQuiz(id) {
if (autoQuiz && !autoQuiz.checked && !quizOnly) return stop('自动测验已关闭,请开启后继续。');
@@ -387,7 +418,7 @@
const option=questions.find(q=>q.id===pending.id)?.options.find(o=>o.key===pending.key);
if (option?.selected===pending.want) quiz.lastClick=null;
else {
if(Date.now()-pending.at>8000) stop('选项点击后没有生效,已暂停,请检查页面。');
if(Date.now()-pending.at>waitLimit(8000)) stop('选项点击后没有生效,已暂停,请检查页面。');
return;
}
}
@@ -444,7 +475,7 @@
}
function tickList() {
if (!packagesAt || packagesAt < mustObservePackagesAfter) {
if (Date.now() - routeSince > 25000) stop('未收到新的课程列表数据,请刷新页面后开始。');
if (Date.now() - routeSince > waitLimit(25000)) stop('未收到新的课程列表数据,请刷新页面后开始。');
return;
}
if (verifyAfterBack) {
@@ -455,7 +486,7 @@
const cards = [...document.querySelectorAll('article.course-row')].filter(visible)
.map(el => ({el, title: el.querySelector('.course-row__title')?.textContent}));
if (!cards.length) {
if (Date.now() - routeSince > 25000) stop('没有找到课程列表,请检查是否已登录。');
if (Date.now() - routeSince > waitLimit(25000)) stop('没有找到课程列表,请检查是否已登录。');
return;
}
const mapped = resolveCards(cards, courses);
@@ -499,7 +530,7 @@
load.overlayTried=true; overlay.click();
}
show(`正在加载:${resource.name}(${Math.floor(elapsed/1000)}秒)`);
if(elapsed>=90000) recoverMedia(id,resource.id,'视频加载超过90秒');
if(elapsed>=waitLimit(90000)) recoverMedia(id,resource.id,`视频加载超过${waitLimit(90000)/1000}秒`);
return false;
}
if(load.video!==video || load.src!==video.currentSrc || !load.settledAt) {
@@ -513,7 +544,7 @@
expectedCourse = null;
const detail = details.get(id);
if (!detail) {
if (Date.now() - routeSince > 20000) stop('未捕获课程详情。安装后请刷新学习页,再点击开始。');
if (Date.now() - routeSince > waitLimit(20000)) stop('未捕获课程详情。安装后请刷新学习页,再点击开始。');
return;
}
const plan = videoPlan(detail);
@@ -524,7 +555,7 @@
active.endedAt ||= Date.now();
if (resource?.completed !== true) {
show('视频已结束,等待平台确认保存…');
if (Date.now() - active.endedAt > 25000) stop('平台尚未确认视频完成,请返回列表核对后再继续。');
if (Date.now() - active.endedAt > waitLimit(25000)) stop('平台尚未确认视频完成,请返回列表核对后再继续。');
return;
}
log('平台已确认本段视频完成。'); active = null;
@@ -544,7 +575,7 @@
if (!button.classList.contains('is-active')) {
if (button.disabled || resource.playable === false) return stop('下一段视频尚未解锁,请按平台提示完成前置学习。');
if (resourceSwitch?.id === resource.id) {
if (Date.now() - resourceSwitch.at > 20000) stop('切换视频没有成功,请手动选择该视频后继续。');
if (Date.now() - resourceSwitch.at > waitLimit(20000)) stop('切换视频没有成功,请手动选择该视频后继续。');
return;
}
resourceSwitch = {id: resource.id, at: Date.now(),previousVideo:currentVideo(),previousSrc:currentVideo()?.currentSrc};
@@ -594,7 +625,7 @@
if(a.phase==='starting') {
if(!v.paused && v.currentTime>a.lastPosition+0.1) { a.phase='playing'; show('2倍速播放中,正在核对平台保存进度。'); }
else {
if(Date.now()-a.started>20000) return recoverMedia(a.courseId,a.id,'视频未能开始播放');
if(Date.now()-a.started>waitLimit(20000)) return recoverMedia(a.courseId,a.id,'视频未能开始播放');
if(v.paused && v.readyState>=2) attemptPlay(a);
return;
}
@@ -602,8 +633,10 @@
if (Math.abs(v.playbackRate - 2) > 0.01) return stop('播放器改变了倍速,已暂停;请检查是否仍有诊断脚本在运行。');
if (v.paused && !v.ended) return stop('视频已暂停;点击助手开始可继续。');
if (v.currentTime > a.lastPosition + 0.1) { a.progressedAt = Date.now(); a.lastPosition = v.currentTime; }
if (Date.now() - a.progressedAt > 60000) return recoverMedia(a.courseId,a.id,'视频超过60秒没有推进');
if (Date.now() - lastNetworkAt > 75000) return stop('超过75秒未收到平台保存确认,已暂停,避免继续播放却未计入进度。');
if (Date.now() - a.progressedAt > waitLimit(60000)) return recoverMedia(a.courseId,a.id,'视频长时间没有推进');
const saveTimeout=waitLimit(75000);
if (Date.now()>foregroundGraceUntil && Date.now()-lastNetworkAt>saveTimeout)
return stop(`超过${saveTimeout/1000}秒未收到平台保存确认,已暂停,避免继续播放却未计入进度。`);
}
async function tick() {
if (!running || busy) return;
@@ -612,7 +645,8 @@
if (!holdLease()) return stop('另一个学习页正在使用助手,或浏览器禁止本地存储。');
if (Date.now() - startAt < 600) return;
if (location.hash !== lastHash) {
const expected = navigatingUntil > Date.now();
const destination=route(location.hash);
const expected=courseNavigationPending && (expectedCourse ? destination.id===expectedCourse : destination.list);
lastHash = location.hash; routeSince = Date.now(); active = null; navigatingUntil = 0;
courseNavigationPending = false; resourceSwitch = null;
if (!expected) return stop('页面已被手动切换;点击开始可继续。');
@@ -637,6 +671,8 @@
: '以平台保存结果为准;电脑需保持唤醒。';
panel.querySelector('[data-start]').disabled = running;
panel.querySelector('[data-quiz-only]').disabled = running;
panel.querySelector('[data-background-state]').textContent = backgroundEnabled
? `${isHidden()?'后台':'前台'} · 切标签页继续播放已开启` : '后台模式已关闭';
mode.disabled = running;
}
function mount() {
@@ -645,15 +681,20 @@
host.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:2147483646';
panel = host.attachShadow({mode: 'open'});
panel.innerHTML = `<style>:host{font:14px/1.6 system-ui;color:#eaf0fa}section{width:290px;background:#17243a;border:1px solid #6883a6;border-radius:12px;padding:15px;box-shadow:0 5px 24px #0005}strong{font-size:16px}p{margin:9px 0;white-space:pre-line}small{color:#b7c7df}button,select{font:inherit;border-radius:6px;border:1px solid #7186a5;padding:5px 9px;margin:3px 5px 3px 0}button{cursor:pointer;background:#dbeaff;color:#123}button:disabled{opacity:.5;cursor:default}select{width:100%;background:#fff;color:#123}[data-status]{min-height:45px}details{margin-top:8px}pre{white-space:pre-wrap;max-height:160px;overflow:auto;font-size:12px}</style>
<section><strong>2倍速学习助手 · 1.1.1</strong><p data-status></p><small data-progress></small>
<section><strong>2倍速学习助手 · 1.2.0</strong><p data-status></p><small data-progress></small>
<select aria-label="播放范围"><option value="all">按列表顺序继续课程</option><option value="current">仅当前课程</option></select>
<div><button data-start>开始 / 继续</button><button data-stop>暂停</button></div>
<label><input type="checkbox" data-quiz checked> 视频完成后自动做测验</label>
<div><label><input type="checkbox" data-background> 切标签页继续播放</label></div>
<small data-background-state></small>
<div><button data-quiz-only>只做本门测验</button></div>
<small>错题根据平台反馈重试;合格后平台可能不允许重考。</small>
<details><summary>运行记录</summary><pre></pre><button data-copy>更新记录</button></details></section>`;
mode = panel.querySelector('select');
autoQuiz = panel.querySelector('[data-quiz]');
const backgroundToggle=panel.querySelector('[data-background]');
backgroundToggle.checked=backgroundEnabled;
backgroundToggle.onchange=()=>setBackgroundEnabled(backgroundToggle.checked);
function beginRun(only, resume) {
if (only && !route(location.hash).id) return show('请先进入需要作答的课程学习页。');
if (!holdLease()) return show('另一个学习页正在运行助手,或本地存储不可用。请先暂停另一个页面。');