Keep normal playback and progress saving active across tab switches
This commit is contained in:
+6
-4
@@ -1,4 +1,4 @@
|
|||||||
# 学员中心 · 2倍速学习助手 v1.1.1
|
# 学员中心 · 2倍速学习助手 v1.2.0
|
||||||
|
|
||||||
适用网页:https://icampus.yunyinsh.com/course/
|
适用网页:https://icampus.yunyinsh.com/course/
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
4. 左下角出现“2倍速学习助手”,点击“开始 / 继续”。默认从列表首个未完成课程开始;已经进入学习页时,先完成当前课程。
|
4. 左下角出现“2倍速学习助手”,点击“开始 / 继续”。默认从列表首个未完成课程开始;已经进入学习页时,先完成当前课程。
|
||||||
5. 可选择“仅当前课程”。按“暂停”会暂停助手当前控制的视频;刷新后不会自行启动。
|
5. 可选择“仅当前课程”。按“暂停”会暂停助手当前控制的视频;刷新后不会自行启动。
|
||||||
6. 默认勾选“视频完成后自动做测验”。也可在学习页点击“只做本门测验”,按网页提供的入口直接进入作答流程。
|
6. 默认勾选“视频完成后自动做测验”。也可在学习页点击“只做本门测验”,按网页提供的入口直接进入作答流程。
|
||||||
|
7. 默认勾选“切标签页继续播放”。先启动助手,再切到其他标签页;无需一直把课程页放在前台。关闭开关会恢复网站原有的隐藏页面处理。
|
||||||
|
|
||||||
## 已实现
|
## 已实现
|
||||||
|
|
||||||
@@ -34,12 +35,13 @@
|
|||||||
- 如果视频结束后25秒内平台仍未确认完成,会停下提示核对,不强行切换。
|
- 如果视频结束后25秒内平台仍未确认完成,会停下提示核对,不强行切换。
|
||||||
- 切换视频后等待新媒体数据就绪;短暂播放中断最多尝试三次。视频加载90秒未就绪、启动20秒未推进或播放停滞60秒时,会尝试一次刷新恢复,每段视频共享一次恢复额度;仍失败则暂停。
|
- 切换视频后等待新媒体数据就绪;短暂播放中断最多尝试三次。视频加载90秒未就绪、启动20秒未推进或播放停滞60秒时,会尝试一次刷新恢复,每段视频共享一次恢复额度;仍失败则暂停。
|
||||||
- 自动恢复刷新会接续当前课程;普通刷新仍默认暂停。临时恢复记录两分钟过期、读取后删除;若手动刷新恰好遇到尚未消费的恢复记录,也可能接续运行。登录失效、前置限制或浏览器拒绝自动播放时,需要手动处理。
|
- 自动恢复刷新会接续当前课程;普通刷新仍默认暂停。临时恢复记录两分钟过期、读取后删除;若手动刷新恰好遇到尚未消费的恢复记录,也可能接续运行。登录失效、前置限制或浏览器拒绝自动播放时,需要手动处理。
|
||||||
- 保持电脑唤醒,建议让课程页在前台。后台计时尚未验证。
|
- 保持电脑唤醒。后台模式已实现并通过模拟测试,但尚未完成真实标签页的长时间实测。Chrome 降低后台定时器频率时切视频、答题可能变慢;后台超时窗口延长至至少180秒,仍无保存响应则暂停。
|
||||||
|
- 电脑休眠、浏览器关闭或标签页被冻结/丢弃时不能保证继续运行。若 Chrome 经常回收该页,可在性能设置中将该站点加入“始终保持活跃的网站”。
|
||||||
- 列表同名课程无法唯一匹配时会停止。可以进入具体课程并选择“仅当前课程”。
|
- 列表同名课程无法唯一匹配时会停止。可以进入具体课程并选择“仅当前课程”。
|
||||||
- 无外部依赖,不上传日志或账户信息。日志仅保存在本页内存;本地存储写入标签页运行锁、题目答案记忆及最近提交快照。标签页会话存储保存短期刷新恢复记录。不保存登录凭据或视频链接。
|
- 无外部依赖,不上传日志或账户信息。日志仅保存在本页内存;本地存储写入标签页运行锁、题目答案记忆、最近提交快照和后台模式偏好。标签页会话存储保存短期刷新恢复记录。不保存登录凭据或视频链接。
|
||||||
|
|
||||||
## 验证情况
|
## 验证情况
|
||||||
|
|
||||||
本地语法检查及41项行为测试通过(16项播放/进度测试、12项测验测试、13项媒体恢复测试)。模拟页面完整跑通开始、选择、交卷、确认、逐题反馈、重试,并在第三次达到100%;验证了正确题不重复改选、无反馈时不重复提交、次数耗尽停止、低于100%合格时不误记整卷全对。此前已实测网页原生2倍速可以持续播放,并收到平台保存响应。
|
本地语法检查及53项行为测试通过(16项播放/进度测试、12项测验测试、13项媒体恢复测试、12项后台播放测试)。模拟页面完整跑通开始、选择、交卷、确认、逐题反馈、重试,并在第三次达到100%;验证了正确题不重复改选、无反馈时不重复提交、次数耗尽停止、低于100%合格时不误记整卷全对。此前已实测网页原生2倍速可以持续播放,并收到平台保存响应。
|
||||||
|
|
||||||
新助手还需要在安装后验证实际页面运行;尚未完成整门课程或全部34门课程的自动播放实测,不能据此保证最终学分或所有课程结构兼容。
|
新助手还需要在安装后验证实际页面运行;尚未完成整门课程或全部34门课程的自动播放实测,不能据此保证最终学分或所有课程结构兼容。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 学员中心 · 2倍速学习助手
|
# 学员中心 · 2倍速学习助手
|
||||||
|
|
||||||
适用于 `https://icampus.yunyinsh.com/course/` 的篡改猴脚本。当前版本 **1.1.1**。
|
适用于 `https://icampus.yunyinsh.com/course/` 的篡改猴脚本。当前版本 **1.2.0**。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
@@ -14,6 +14,17 @@
|
|||||||
- 单选、判断、多选自动作答、交卷,根据平台逐题反馈保存答案并重试。
|
- 单选、判断、多选自动作答、交卷,根据平台逐题反馈保存答案并重试。
|
||||||
- 保留正确答案,排除错误选项或组合;按题干和选项文字匹配,避免选项字母换序导致误答。
|
- 保留正确答案,排除错误选项或组合;按题干和选项文字匹配,避免选项字母换序导致误答。
|
||||||
- 自动暂停、异常提示、单标签页运行锁,本机答案记忆。
|
- 自动暂停、异常提示、单标签页运行锁,本机答案记忆。
|
||||||
|
- 默认开启“切标签页继续播放”,助手运行时保留网站原有的后台进度保存定时器。
|
||||||
|
|
||||||
|
## 1.2.0 后台播放
|
||||||
|
|
||||||
|
安装后刷新课程页,点击“开始 / 继续”,保持“切标签页继续播放”勾选,然后切换其他标签页。开关偏好会保存在本机。
|
||||||
|
|
||||||
|
已加载的课程组件在页面隐藏时主动停止心跳。后台模式在课程页的助手运行期间拦截这个隐藏事件,保留正常播放和网页原本的心跳;不会改写 `document.visibilityState`、播放位置或服务器请求。回到前台仍执行网站原来的恢复逻辑。暂停助手、关闭后台模式或离开页面时恢复正常处理。
|
||||||
|
|
||||||
|
考虑到 Chrome 可能降低后台定时器频率,后台等待窗口及标签页运行锁延长至至少180秒,返回前台后给保存请求20秒恢复时间。连续180秒没有保存确认仍会暂停,后台切换视频和答题可能比前台慢。
|
||||||
|
|
||||||
|
电脑休眠、浏览器关闭、标签页冻结/丢弃、断网或媒体自动播放被浏览器拒绝,无法靠网页脚本保证运行。若 Chrome 经常回收该页,可在性能设置中将该站点加入“始终保持活跃的网站”;脚本不会自动修改浏览器设置。
|
||||||
|
|
||||||
## 1.1.1 修复
|
## 1.1.1 修复
|
||||||
|
|
||||||
@@ -25,10 +36,12 @@
|
|||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
运行 `npm test`,无需安装依赖。当前41项本地行为测试覆盖播放、进度保存、测验循环及媒体加载恢复。
|
运行 `npm test`,无需安装依赖。当前53项本地行为测试覆盖播放、进度保存、测验循环、媒体加载恢复及后台模式。
|
||||||
|
|
||||||
现场已确认原问题出现时第二段视频处于 `readyState=0` 且旧版已超时停止;修复通过模拟播放器与页面回归。尚未安装修复版验证真实课程完整连播。
|
现场已确认原问题出现时第二段视频处于 `readyState=0` 且旧版已超时停止;修复通过模拟播放器与页面回归。尚未安装修复版验证真实课程完整连播。
|
||||||
|
|
||||||
|
后台模式测试模拟了网页真实的可见性处理和心跳响应,验证拦截、恢复、暂停、后台超时和延迟跳转等情况。尚未在用户浏览器中完成新版的长时间真实后台运行验证。
|
||||||
|
|
||||||
## 限制
|
## 限制
|
||||||
|
|
||||||
计入进度、课程完成和重试次数均以平台返回结果为准。合格后平台若关闭重考入口,无法继续重考到100%。图片、材料、填空和问答题需要人工处理。请保持电脑唤醒。
|
计入进度、课程完成和重试次数均以平台返回结果为准。合格后平台若关闭重考入口,无法继续重考到100%。图片、材料、填空和问答题需要人工处理。请保持电脑唤醒。
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
'use strict';
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const {harness,ID,RID}=require('./study-helper.test.cjs');
|
||||||
|
let passed=0;
|
||||||
|
async function test(name,fn){await fn();passed++;console.log(`PASS ${name}`);}
|
||||||
|
function visibility(h,state){h.context.document.visibilityState=state;const event=new h.context.Event('visibilitychange');h.context.document.dispatchEvent(event);return event;}
|
||||||
|
function site(h){
|
||||||
|
let timer=true,calls=0;
|
||||||
|
// Mirrors the observed course component: hidden stops, visible restarts.
|
||||||
|
h.context.document.addEventListener('visibilitychange',()=>{calls++;timer=h.context.document.visibilityState!=='hidden';});
|
||||||
|
return {get timer(){return timer;},get calls(){return calls;},heartbeat(){if(timer&&!h.video.paused){
|
||||||
|
h.api.receive({id:ID,tail:'sessions/session/heartbeat',body:{resourceId:RID}},
|
||||||
|
{bizCode:0,data:{resourceCompleted:false,currentPositionSeconds:h.video.currentTime,courseProgressPercent:10}},200);
|
||||||
|
}}};
|
||||||
|
}
|
||||||
|
(async()=>{
|
||||||
|
await test('hidden event keeps the website heartbeat alive while real visibility stays hidden',()=>{
|
||||||
|
const h=harness(),web=site(h);h.api.begin();h.api.track(h.video);
|
||||||
|
const event=visibility(h,'hidden');assert.equal(event.stopped,true);assert.equal(web.timer,true);assert.equal(web.calls,0);
|
||||||
|
assert.equal(h.context.document.visibilityState,'hidden');assert.equal(h.video.playbackRate,2);
|
||||||
|
h.advance(15000);h.video.currentTime+=30;web.heartbeat();assert.equal(h.api.state.active.saved,true);
|
||||||
|
assert.equal(h.api.state.active.serverPosition,150);
|
||||||
|
});
|
||||||
|
await test('an idle assistant does not intercept website visibility handling',()=>{
|
||||||
|
const h=harness(),web=site(h);visibility(h,'hidden');assert.equal(web.timer,false);assert.equal(web.calls,1);
|
||||||
|
});
|
||||||
|
await test('returning to foreground lets the normal website handler run',()=>{
|
||||||
|
const h=harness(),web=site(h);h.api.begin();visibility(h,'hidden');visibility(h,'visible');
|
||||||
|
assert.equal(web.calls,1);assert.equal(web.timer,true);
|
||||||
|
});
|
||||||
|
await test('turning off background mode restores hidden behavior and persists the preference',()=>{
|
||||||
|
const h=harness(),web=site(h);h.api.begin();visibility(h,'hidden');h.api.setBackgroundEnabled(false);
|
||||||
|
assert.equal(web.timer,false);assert.equal(h.storage.get('icampus.study.v1.background'),'off');
|
||||||
|
assert.equal(visibility(h,'hidden').stopped,undefined);
|
||||||
|
});
|
||||||
|
await test('stop while hidden pauses media and restores website cleanup',()=>{
|
||||||
|
const h=harness(),web=site(h);h.api.begin();h.api.track(h.video);visibility(h,'hidden');h.api.stop();
|
||||||
|
assert.equal(web.timer,false);assert.equal(h.video.paused,true);assert.equal(h.api.state.running,false);
|
||||||
|
});
|
||||||
|
await test('pagehide is not suppressed when the tab is closed or reloaded',()=>{
|
||||||
|
const h=harness();let cleaned=false;h.window.addEventListener('pagehide',()=>{cleaned=true;});h.api.begin();visibility(h,'hidden');
|
||||||
|
h.window.dispatchEvent(new h.context.Event('pagehide'));assert.equal(cleaned,true);assert.equal(h.api.state.running,false);
|
||||||
|
});
|
||||||
|
await test('a 60-second background timer delay does not expire the active tab lease',()=>{
|
||||||
|
const h=harness();h.api.begin();visibility(h,'hidden');h.advance(61000);
|
||||||
|
const lease=JSON.parse(h.storage.get('icampus.study.v1.lease'));assert.ok(lease.until>h.context.Date.now());
|
||||||
|
});
|
||||||
|
await test('background delay allows a real heartbeat to arrive but missing saves still stop',()=>{
|
||||||
|
const h=harness();h.api.begin();h.api.track(h.video);visibility(h,'hidden');
|
||||||
|
h.advance(80000);h.video.currentTime+=160;h.api.monitor();assert.equal(h.api.state.running,true);
|
||||||
|
h.advance(101000);h.video.currentTime+=202;h.api.monitor();assert.equal(h.api.state.running,false);assert.match(h.api.state.status,/180秒/);
|
||||||
|
});
|
||||||
|
await test('foreground return gives the restarted heartbeat 20 seconds to respond',()=>{
|
||||||
|
const h=harness();h.api.begin();h.api.track(h.video);visibility(h,'hidden');h.advance(80000);visibility(h,'visible');
|
||||||
|
h.video.currentTime+=160;h.api.monitor();assert.equal(h.api.state.running,true);
|
||||||
|
h.advance(21000);h.video.currentTime+=42;h.api.monitor();assert.equal(h.api.state.running,false);
|
||||||
|
});
|
||||||
|
await test('manual pause is respected in background mode',()=>{
|
||||||
|
const h=harness();h.api.begin();h.api.track(h.video);visibility(h,'hidden');h.video.paused=true;h.api.monitor();
|
||||||
|
assert.equal(h.api.state.running,false);assert.equal(h.video.playCount,0);
|
||||||
|
});
|
||||||
|
await test('successful course navigation survives a delayed background tick',async()=>{
|
||||||
|
const h=harness();h.api.begin();visibility(h,'hidden');h.api.pendingCourse('123');h.context.location.hash='#/my-course/learn?id=123';
|
||||||
|
h.advance(61000);await h.api.tick();assert.equal(h.api.state.running,true);
|
||||||
|
});
|
||||||
|
await test('non-course routes do not suppress hidden events',()=>{
|
||||||
|
const h=harness(),web=site(h);h.api.begin();h.context.location.hash='#/user';visibility(h,'hidden');assert.equal(web.timer,false);
|
||||||
|
});
|
||||||
|
console.log(`${passed} background playback tests passed.`);
|
||||||
|
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name 学员中心 · 2倍速学习助手
|
// @name 学员中心 · 2倍速学习助手
|
||||||
// @namespace local.icampus.study
|
// @namespace local.icampus.study
|
||||||
// @version 1.1.1
|
// @version 1.2.0
|
||||||
// @description 2倍速顺序播放、测验自动作答、按逐题反馈记忆答案并重试。
|
// @description 支持切换标签页继续2倍速播放与保存进度,自动测验及错题重试。
|
||||||
// @match https://icampus.yunyinsh.com/course/*
|
// @match https://icampus.yunyinsh.com/course/*
|
||||||
// @run-at document-start
|
// @run-at document-start
|
||||||
// @grant none
|
// @grant none
|
||||||
@@ -100,6 +100,10 @@
|
|||||||
const BANK_KEY = 'icampus.study.v1.answers';
|
const BANK_KEY = 'icampus.study.v1.answers';
|
||||||
const SNAPSHOT_KEY = 'icampus.study.v1.lastExam';
|
const SNAPSHOT_KEY = 'icampus.study.v1.lastExam';
|
||||||
const RESUME_KEY = 'icampus.study.v1.recovery';
|
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 mediaLoad = null;
|
||||||
let recoveryUsed = new Set();
|
let recoveryUsed = new Set();
|
||||||
let bank;
|
let bank;
|
||||||
@@ -143,13 +147,39 @@
|
|||||||
}
|
}
|
||||||
function show(message) { if (status !== message) { status = message; log(message); } render(); }
|
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; } }
|
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() {
|
function holdLease() {
|
||||||
if (!leaseAvailable(readLease(), owner, Date.now())) return false;
|
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; }
|
catch { return false; }
|
||||||
return readLease()?.owner === owner;
|
return readLease()?.owner === owner;
|
||||||
}
|
}
|
||||||
function releaseLease() { try { if (readLease()?.owner === owner) localStorage.removeItem(LEASE_KEY); } catch {} }
|
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 = '已暂停;点击开始可继续。') {
|
function stop(message = '已暂停;点击开始可继续。') {
|
||||||
running = false; ++runNumber;
|
running = false; ++runNumber;
|
||||||
const video = active?.video;
|
const video = active?.video;
|
||||||
@@ -157,6 +187,7 @@
|
|||||||
mediaLoad = null;
|
mediaLoad = null;
|
||||||
try { sessionStorage.removeItem(RESUME_KEY); } catch {}
|
try { sessionStorage.removeItem(RESUME_KEY); } catch {}
|
||||||
if (video && !video.paused) video.pause();
|
if (video && !video.paused) video.pause();
|
||||||
|
restoreVisibilityHandling();
|
||||||
releaseLease(); show(message);
|
releaseLease(); show(message);
|
||||||
}
|
}
|
||||||
function safeCourse(c) {
|
function safeCourse(c) {
|
||||||
@@ -334,7 +365,7 @@
|
|||||||
}
|
}
|
||||||
function quizWait(message, timeout = 25000) {
|
function quizWait(message, timeout = 25000) {
|
||||||
show(message);
|
show(message);
|
||||||
if (Date.now() - quiz.since > timeout) stop('测验页面未在预期时间内响应。已保留答案记忆,请检查网页后继续。');
|
if (Date.now() - quiz.since > waitLimit(timeout)) stop('测验页面未在预期时间内响应。已保留答案记忆,请检查网页后继续。');
|
||||||
}
|
}
|
||||||
function tickQuiz(id) {
|
function tickQuiz(id) {
|
||||||
if (autoQuiz && !autoQuiz.checked && !quizOnly) return stop('自动测验已关闭,请开启后继续。');
|
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);
|
const option=questions.find(q=>q.id===pending.id)?.options.find(o=>o.key===pending.key);
|
||||||
if (option?.selected===pending.want) quiz.lastClick=null;
|
if (option?.selected===pending.want) quiz.lastClick=null;
|
||||||
else {
|
else {
|
||||||
if(Date.now()-pending.at>8000) stop('选项点击后没有生效,已暂停,请检查页面。');
|
if(Date.now()-pending.at>waitLimit(8000)) stop('选项点击后没有生效,已暂停,请检查页面。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,7 +475,7 @@
|
|||||||
}
|
}
|
||||||
function tickList() {
|
function tickList() {
|
||||||
if (!packagesAt || packagesAt < mustObservePackagesAfter) {
|
if (!packagesAt || packagesAt < mustObservePackagesAfter) {
|
||||||
if (Date.now() - routeSince > 25000) stop('未收到新的课程列表数据,请刷新页面后开始。');
|
if (Date.now() - routeSince > waitLimit(25000)) stop('未收到新的课程列表数据,请刷新页面后开始。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (verifyAfterBack) {
|
if (verifyAfterBack) {
|
||||||
@@ -455,7 +486,7 @@
|
|||||||
const cards = [...document.querySelectorAll('article.course-row')].filter(visible)
|
const cards = [...document.querySelectorAll('article.course-row')].filter(visible)
|
||||||
.map(el => ({el, title: el.querySelector('.course-row__title')?.textContent}));
|
.map(el => ({el, title: el.querySelector('.course-row__title')?.textContent}));
|
||||||
if (!cards.length) {
|
if (!cards.length) {
|
||||||
if (Date.now() - routeSince > 25000) stop('没有找到课程列表,请检查是否已登录。');
|
if (Date.now() - routeSince > waitLimit(25000)) stop('没有找到课程列表,请检查是否已登录。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const mapped = resolveCards(cards, courses);
|
const mapped = resolveCards(cards, courses);
|
||||||
@@ -499,7 +530,7 @@
|
|||||||
load.overlayTried=true; overlay.click();
|
load.overlayTried=true; overlay.click();
|
||||||
}
|
}
|
||||||
show(`正在加载:${resource.name}(${Math.floor(elapsed/1000)}秒)`);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
if(load.video!==video || load.src!==video.currentSrc || !load.settledAt) {
|
if(load.video!==video || load.src!==video.currentSrc || !load.settledAt) {
|
||||||
@@ -513,7 +544,7 @@
|
|||||||
expectedCourse = null;
|
expectedCourse = null;
|
||||||
const detail = details.get(id);
|
const detail = details.get(id);
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
if (Date.now() - routeSince > 20000) stop('未捕获课程详情。安装后请刷新学习页,再点击开始。');
|
if (Date.now() - routeSince > waitLimit(20000)) stop('未捕获课程详情。安装后请刷新学习页,再点击开始。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const plan = videoPlan(detail);
|
const plan = videoPlan(detail);
|
||||||
@@ -524,7 +555,7 @@
|
|||||||
active.endedAt ||= Date.now();
|
active.endedAt ||= Date.now();
|
||||||
if (resource?.completed !== true) {
|
if (resource?.completed !== true) {
|
||||||
show('视频已结束,等待平台确认保存…');
|
show('视频已结束,等待平台确认保存…');
|
||||||
if (Date.now() - active.endedAt > 25000) stop('平台尚未确认视频完成,请返回列表核对后再继续。');
|
if (Date.now() - active.endedAt > waitLimit(25000)) stop('平台尚未确认视频完成,请返回列表核对后再继续。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log('平台已确认本段视频完成。'); active = null;
|
log('平台已确认本段视频完成。'); active = null;
|
||||||
@@ -544,7 +575,7 @@
|
|||||||
if (!button.classList.contains('is-active')) {
|
if (!button.classList.contains('is-active')) {
|
||||||
if (button.disabled || resource.playable === false) return stop('下一段视频尚未解锁,请按平台提示完成前置学习。');
|
if (button.disabled || resource.playable === false) return stop('下一段视频尚未解锁,请按平台提示完成前置学习。');
|
||||||
if (resourceSwitch?.id === resource.id) {
|
if (resourceSwitch?.id === resource.id) {
|
||||||
if (Date.now() - resourceSwitch.at > 20000) stop('切换视频没有成功,请手动选择该视频后继续。');
|
if (Date.now() - resourceSwitch.at > waitLimit(20000)) stop('切换视频没有成功,请手动选择该视频后继续。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resourceSwitch = {id: resource.id, at: Date.now(),previousVideo:currentVideo(),previousSrc:currentVideo()?.currentSrc};
|
resourceSwitch = {id: resource.id, at: Date.now(),previousVideo:currentVideo(),previousSrc:currentVideo()?.currentSrc};
|
||||||
@@ -594,7 +625,7 @@
|
|||||||
if(a.phase==='starting') {
|
if(a.phase==='starting') {
|
||||||
if(!v.paused && v.currentTime>a.lastPosition+0.1) { a.phase='playing'; show('2倍速播放中,正在核对平台保存进度。'); }
|
if(!v.paused && v.currentTime>a.lastPosition+0.1) { a.phase='playing'; show('2倍速播放中,正在核对平台保存进度。'); }
|
||||||
else {
|
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);
|
if(v.paused && v.readyState>=2) attemptPlay(a);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -602,8 +633,10 @@
|
|||||||
if (Math.abs(v.playbackRate - 2) > 0.01) return stop('播放器改变了倍速,已暂停;请检查是否仍有诊断脚本在运行。');
|
if (Math.abs(v.playbackRate - 2) > 0.01) return stop('播放器改变了倍速,已暂停;请检查是否仍有诊断脚本在运行。');
|
||||||
if (v.paused && !v.ended) 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 (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() - a.progressedAt > waitLimit(60000)) return recoverMedia(a.courseId,a.id,'视频长时间没有推进');
|
||||||
if (Date.now() - lastNetworkAt > 75000) return stop('超过75秒未收到平台保存确认,已暂停,避免继续播放却未计入进度。');
|
const saveTimeout=waitLimit(75000);
|
||||||
|
if (Date.now()>foregroundGraceUntil && Date.now()-lastNetworkAt>saveTimeout)
|
||||||
|
return stop(`超过${saveTimeout/1000}秒未收到平台保存确认,已暂停,避免继续播放却未计入进度。`);
|
||||||
}
|
}
|
||||||
async function tick() {
|
async function tick() {
|
||||||
if (!running || busy) return;
|
if (!running || busy) return;
|
||||||
@@ -612,7 +645,8 @@
|
|||||||
if (!holdLease()) return stop('另一个学习页正在使用助手,或浏览器禁止本地存储。');
|
if (!holdLease()) return stop('另一个学习页正在使用助手,或浏览器禁止本地存储。');
|
||||||
if (Date.now() - startAt < 600) return;
|
if (Date.now() - startAt < 600) return;
|
||||||
if (location.hash !== lastHash) {
|
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;
|
lastHash = location.hash; routeSince = Date.now(); active = null; navigatingUntil = 0;
|
||||||
courseNavigationPending = false; resourceSwitch = null;
|
courseNavigationPending = false; resourceSwitch = null;
|
||||||
if (!expected) return stop('页面已被手动切换;点击开始可继续。');
|
if (!expected) return stop('页面已被手动切换;点击开始可继续。');
|
||||||
@@ -637,6 +671,8 @@
|
|||||||
: '以平台保存结果为准;电脑需保持唤醒。';
|
: '以平台保存结果为准;电脑需保持唤醒。';
|
||||||
panel.querySelector('[data-start]').disabled = running;
|
panel.querySelector('[data-start]').disabled = running;
|
||||||
panel.querySelector('[data-quiz-only]').disabled = running;
|
panel.querySelector('[data-quiz-only]').disabled = running;
|
||||||
|
panel.querySelector('[data-background-state]').textContent = backgroundEnabled
|
||||||
|
? `${isHidden()?'后台':'前台'} · 切标签页继续播放已开启` : '后台模式已关闭';
|
||||||
mode.disabled = running;
|
mode.disabled = running;
|
||||||
}
|
}
|
||||||
function mount() {
|
function mount() {
|
||||||
@@ -645,15 +681,20 @@
|
|||||||
host.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:2147483646';
|
host.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:2147483646';
|
||||||
panel = host.attachShadow({mode: 'open'});
|
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>
|
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>
|
<select aria-label="播放范围"><option value="all">按列表顺序继续课程</option><option value="current">仅当前课程</option></select>
|
||||||
<div><button data-start>开始 / 继续</button><button data-stop>暂停</button></div>
|
<div><button data-start>开始 / 继续</button><button data-stop>暂停</button></div>
|
||||||
<label><input type="checkbox" data-quiz checked> 视频完成后自动做测验</label>
|
<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>
|
<div><button data-quiz-only>只做本门测验</button></div>
|
||||||
<small>错题根据平台反馈重试;合格后平台可能不允许重考。</small>
|
<small>错题根据平台反馈重试;合格后平台可能不允许重考。</small>
|
||||||
<details><summary>运行记录</summary><pre></pre><button data-copy>更新记录</button></details></section>`;
|
<details><summary>运行记录</summary><pre></pre><button data-copy>更新记录</button></details></section>`;
|
||||||
mode = panel.querySelector('select');
|
mode = panel.querySelector('select');
|
||||||
autoQuiz = panel.querySelector('[data-quiz]');
|
autoQuiz = panel.querySelector('[data-quiz]');
|
||||||
|
const backgroundToggle=panel.querySelector('[data-background]');
|
||||||
|
backgroundToggle.checked=backgroundEnabled;
|
||||||
|
backgroundToggle.onchange=()=>setBackgroundEnabled(backgroundToggle.checked);
|
||||||
function beginRun(only, resume) {
|
function beginRun(only, resume) {
|
||||||
if (only && !route(location.hash).id) return show('请先进入需要作答的课程学习页。');
|
if (only && !route(location.hash).id) return show('请先进入需要作答的课程学习页。');
|
||||||
if (!holdLease()) return show('另一个学习页正在运行助手,或本地存储不可用。请先暂停另一个页面。');
|
if (!holdLease()) return show('另一个学习页正在运行助手,或本地存储不可用。请先暂停另一个页面。');
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "e-learning-assistant",
|
"name": "e-learning-assistant",
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "学员中心2倍速播放和测验辅助脚本",
|
"description": "学员中心2倍速播放和测验辅助脚本",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --check icampus-study-helper.user.js && node study-helper.test.cjs && node quiz-helper.test.cjs && node playback-recovery.test.cjs"
|
"test": "node --check icampus-study-helper.user.js && node study-helper.test.cjs && node quiz-helper.test.cjs && node playback-recovery.test.cjs && node background-playback.test.cjs"
|
||||||
},
|
},
|
||||||
"engines": { "node": ">=18" }
|
"engines": { "node": ">=18" }
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-4
@@ -17,7 +17,13 @@ function harness() {
|
|||||||
let now = 100000;
|
let now = 100000;
|
||||||
class TestDate extends Date { static now() { return now; } }
|
class TestDate extends Date { static now() { return now; } }
|
||||||
const storage = new Map(), session = new Map(), selectors = new Map();
|
const storage = new Map(), session = new Map(), selectors = new Map();
|
||||||
const document = {body: null, getElementById: () => null, addEventListener() {},
|
function eventSurface(){
|
||||||
|
const listeners=new Map();
|
||||||
|
return {addEventListener(type,fn,options){const list=listeners.get(type)||[];list.push({fn,capture:options===true||!!options?.capture});listeners.set(type,list);},
|
||||||
|
dispatchEvent(event){for(const item of [...(listeners.get(event.type)||[])].sort((a,b)=>Number(b.capture)-Number(a.capture))){item.fn(event);if(event.stopped)break;}return true;}};
|
||||||
|
}
|
||||||
|
class TestEvent {constructor(type){this.type=type;}stopImmediatePropagation(){this.stopped=true;}}
|
||||||
|
const document = {...eventSurface(),body: null, visibilityState:'visible',getElementById: () => null,
|
||||||
querySelector: s => selectors.get(s) || null, querySelectorAll: s => selectors.get(s) || []};
|
querySelector: s => selectors.get(s) || null, querySelectorAll: s => selectors.get(s) || []};
|
||||||
class XHR {
|
class XHR {
|
||||||
open(...args) { this.openArgs = args; }
|
open(...args) { this.openArgs = args; }
|
||||||
@@ -25,17 +31,18 @@ function harness() {
|
|||||||
addEventListener(name, fn) { this[name] = fn; }
|
addEventListener(name, fn) { this[name] = fn; }
|
||||||
}
|
}
|
||||||
const pending = new Promise(() => {});
|
const pending = new Promise(() => {});
|
||||||
const window = {fetch() { return pending; }, addEventListener() {}};
|
const window = {...eventSurface(),fetch() { return pending; }};
|
||||||
window.self = window.top = window;
|
window.self = window.top = window;
|
||||||
const context = {window, document, XMLHttpRequest: XHR, location: {
|
const context = {window, document, XMLHttpRequest: XHR, location: {
|
||||||
href: `https://icampus.yunyinsh.com/course/#/my-course/learn?id=${ID}`, hash: `#/my-course/learn?id=${ID}`, reload(){this.reloadCount=(this.reloadCount||0)+1;}},
|
href: `https://icampus.yunyinsh.com/course/#/my-course/learn?id=${ID}`, hash: `#/my-course/learn?id=${ID}`, reload(){this.reloadCount=(this.reloadCount||0)+1;}},
|
||||||
URL, URLSearchParams, Date: TestDate, crypto: {randomUUID: () => 'test-owner'}, TextDecoder,
|
URL, URLSearchParams, Date: TestDate, crypto: {randomUUID: () => 'test-owner'}, TextDecoder,Event:TestEvent,
|
||||||
localStorage: {getItem: k => storage.get(k) ?? null, setItem: (k,v) => storage.set(k,v), removeItem: k => storage.delete(k)},
|
localStorage: {getItem: k => storage.get(k) ?? null, setItem: (k,v) => storage.set(k,v), removeItem: k => storage.delete(k)},
|
||||||
sessionStorage: {getItem:k=>session.get(k)??null,setItem:(k,v)=>session.set(k,v),removeItem:k=>session.delete(k)},
|
sessionStorage: {getItem:k=>session.get(k)??null,setItem:(k,v)=>session.set(k,v),removeItem:k=>session.delete(k)},
|
||||||
setInterval() {}};
|
setInterval() {}};
|
||||||
const source = fs.readFileSync('./icampus-study-helper.user.js', 'utf8').replace(
|
const source = fs.readFileSync('./icampus-study-helper.user.js', 'utf8').replace(
|
||||||
'setInterval(() => { void tick(); }, 1000);',
|
'setInterval(() => { void tick(); }, 1000);',
|
||||||
`window.testing = {receive, stop, tick, tickCourse, tickQuiz, monitor, details, holdLease, waitMedia, recoverMedia, mount,
|
`window.testing = {receive, stop, tick, tickCourse, tickQuiz, monitor, details, holdLease, waitMedia, recoverMedia, mount, setBackgroundEnabled,
|
||||||
|
pendingCourse(id){expectedCourse=id;courseNavigationPending=true;navigatingUntil=Date.now()+20000;},
|
||||||
recovered(id){recoveryUsed.add('${ID}/'+id);},
|
recovered(id){recoveryUsed.add('${ID}/'+id);},
|
||||||
begin(){running=true;runNumber++;mode={value:'all'};startAt=Date.now()-1000;},
|
begin(){running=true;runNumber++;mode={value:'all'};startAt=Date.now()-1000;},
|
||||||
track(v){active={video:v,id:'${RID}',courseId:'${ID}',src:v.currentSrc,lastPosition:v.currentTime,progressedAt:Date.now(),started:Date.now()};lastNetworkAt=Date.now();},
|
track(v){active={video:v,id:'${RID}',courseId:'${ID}',src:v.currentSrc,lastPosition:v.currentTime,progressedAt:Date.now(),started:Date.now()};lastNetworkAt=Date.now();},
|
||||||
|
|||||||
Reference in New Issue
Block a user