Add persistent full-course mute option
This commit is contained in:
+17
-8
@@ -1,6 +1,6 @@
|
||||
# 学员中心 · 2倍速学习助手使用说明
|
||||
|
||||
适用版本:**1.4.0**。浏览器设置核对日期:**2026-10-06**。
|
||||
适用版本:**1.4.1**。浏览器设置核对日期:**2026-10-06**。
|
||||
|
||||
[仓库首页](README.md) · [脚本文件](icampus-study-helper.user.js) · [更新记录](CHANGELOG.md)
|
||||
|
||||
@@ -37,9 +37,9 @@ Chrome不能在无痕窗口内安装扩展,应先普通窗口安装再允许
|
||||
2. 点击文件页面的“下载”,或打开“原始文件 / Raw”后保存。文件应名为 `icampus-study-helper.user.js`,不能是仓库HTML网页或误存的 `.txt`。下载整个仓库压缩包的用户先解压。
|
||||
3. 点击篡改猴图标 →“管理面板 / Dashboard”→“实用工具 / Utilities”。
|
||||
4. 找到“从文件导入 / Import from file”文件选择按钮,选中该 `.user.js`。
|
||||
5. 在安装/导入页面核对名称“学员中心 · 2倍速学习助手”和版本 **1.4.0**,按提示确认安装或更新。仅选择文件不等于安装完成。
|
||||
5. 在安装/导入页面核对名称“学员中心 · 2倍速学习助手”和版本 **1.4.1**,按提示确认安装或更新。仅选择文件不等于安装完成。
|
||||
6. 回到“已安装脚本”,确认开关开启。
|
||||
7. 刷新课程页,确认左下角版本为 **1.4.0**。
|
||||
7. 刷新课程页,确认左下角版本为 **1.4.1**。
|
||||
|
||||
篡改猴导入入口见 [官方导入导出说明](https://www.tampermonkey.net/faq.php?locale=zh&q=Q106)。界面文字可能因版本不同而变化;若该入口不接受 `.user.js`,直接用下一节复制方式,无需将脚本改成JSON。
|
||||
|
||||
@@ -63,7 +63,7 @@ Chrome不能在无痕窗口内安装扩展,应先普通窗口安装再允许
|
||||
1. 停用旧诊断脚本、倍速实验脚本和重复助手。
|
||||
2. 登录网站,打开“我的课程”,展开要学习的分组,清除不需要的筛选,确认目标课程已显示。
|
||||
3. 选择“按列表顺序继续课程”,或进入具体课程选择“仅当前课程”。
|
||||
4. 默认保留自动测验、后台播放和受限时尝试静音三个勾选,点击“开始”。
|
||||
4. 默认保留自动测验、后台播放和受限时尝试静音三个勾选;需要全程无声音时,再勾选“全过程静音”,点击“开始”。
|
||||
5. 需要停止时点击“人工取消”,之后刷新不会自动开始。
|
||||
|
||||
| 控件/状态 | 含义 |
|
||||
@@ -73,6 +73,7 @@ Chrome不能在无痕窗口内安装扩展,应先普通窗口安装再允许
|
||||
| 只做本门测验 | 先进入课程再点击,只处理该课程测验 |
|
||||
| 自动测验,按平台合格线通过 | 自动答题、交卷和未合格重试;关闭后未合格测验需手动处理 |
|
||||
| 切标签页继续播放 | 运行期间保留网站正常后台播放与保存流程 |
|
||||
| 全过程静音 | 默认关闭;勾选后立即静音课程播放器,切换视频/课程、后台和刷新后继续保持 |
|
||||
| 播放受限时尝试静音 | 有声自动播放被拒绝时尝试一次静音;成功后面板显示“静音” |
|
||||
| 已启动 · 等待恢复 | 仍启用,查看提示;普通等待会定期检查或随页面变化接续 |
|
||||
| 人工取消 | 关闭自动运行并暂停助手控制的视频 |
|
||||
@@ -80,6 +81,14 @@ Chrome不能在无痕窗口内安装扩展,应先普通窗口安装再允许
|
||||
|
||||
更换运行范围或“只做本门测验”前,先人工取消。启动状态保存在当前标签页会话中,同一页普通刷新和自动恢复刷新都可接续。新标签页、浏览器重启、结束无痕会话不保证保留。手动暂停视频时,助手等待手动播放,不抢占暂停操作。
|
||||
|
||||
### 全过程静音
|
||||
|
||||
勾选 **“全过程静音”** 后,当前课程播放器立即静音,不用等待点击开始;每次启动、加载新视频或更换课程播放器都会应用静音,网页或用户在播放器中取消静音时也会重新静音。播放位置、音量滑块和播放速度不因该开关改变。
|
||||
|
||||
开关开启期间,等待、后台和人工取消后仍保持静音。要恢复声音,应先取消勾选“全过程静音”;播放器将恢复开启前的静音状态,原本已静音的播放器不会被强行打开声音。必要时再用播放器声音按钮取消静音。
|
||||
|
||||
偏好保存在课程站点本地存储,刷新后自动恢复;普通和无痕环境分别保存,无痕会话结束后可能丢失。该开关控制课程播放器,不影响其他网站或系统声音。“播放受限时尝试静音”仍是独立的自动播放恢复选项:无论该选项是否开启,全过程静音开启时都会保持静音。
|
||||
|
||||
## 5. 完成条件与检查范围
|
||||
|
||||
换课条件为 **学习进度完成,且需要测验的课程已由平台判定合格**。有明确学习进度时检查100%;没有该字段才参考平台课程完成标记。合格线由平台判断,脚本不根据分数猜测或写死阈值。
|
||||
@@ -158,7 +167,7 @@ Chrome经常回收课程页时,可在“设置 → 性能 → 始终保持活
|
||||
|
||||
| 保存位置 | 内容 |
|
||||
| --- | --- |
|
||||
| 网站本地存储 | 答案记忆、最近提交快照、后台和静音回退偏好、标签页运行锁 |
|
||||
| 网站本地存储 | 答案记忆、最近提交快照、后台、全过程静音和静音回退偏好、标签页运行锁 |
|
||||
| 当前标签页会话存储 | 启用状态、运行范围和刷新恢复记录 |
|
||||
| 当前页内存 | 运行记录、已读取课程列表和时长样本 |
|
||||
|
||||
@@ -183,14 +192,14 @@ Chrome经常回收课程页时,可在“设置 → 性能 → 始终保持活
|
||||
| 另一个学习页运行 | 在另一个页面人工取消或关闭,等待运行锁释放 |
|
||||
| 课程重名 | 手动进入目标课程,选仅当前课程 |
|
||||
| 登录、前置限制、无保存确认 | 按平台提示处理后刷新,同一标签页可接续 |
|
||||
| 视频没有声音 | 查看是否标记静音,可在播放器恢复声音或关闭静音回退选项 |
|
||||
| 视频没有声音 | 如勾选了全过程静音,先关闭该开关;再检查播放器原有静音状态或受限播放的静音回退 |
|
||||
|
||||
反馈时展开“运行记录”并点“更新记录”,提供脚本版本、普通/无痕模式、运行范围、面板文字和必要记录,不要附凭据或完整敏感抓包。
|
||||
|
||||
## 12. 验证与恢复边界
|
||||
|
||||
81项本地行为测试:16播放/进度、13测验、13媒体恢复、12后台、10持续运行/完成条件、17统计/自动播放回退。覆盖低于100分但已合格换课、刷新接续、缺失时长、暂停预测、静音成功和仍被拒绝等情况。
|
||||
91项本地行为测试:16播放/进度、13测验、13媒体恢复、12后台、10持续运行/完成条件、17统计/自动播放回退、10全过程静音。覆盖低于100分但已合格换课、刷新接续、缺失时长、暂停预测、静音成功和仍被拒绝,以及静音开关、播放器更换、声音重置、后台与原始状态恢复等情况。
|
||||
|
||||
现场已观察到网页原生2倍速、平台保存响应,以及本次点击播放后恢复。新版整体统计、估算和静音回退经过模拟测试,尚未完成所有真实课程和长时间后台端到端验证,不能保证最终学分或全部课程结构兼容。
|
||||
现场此前已观察到网页原生2倍速、平台保存响应及点击播放后恢复。全过程静音经过本地模拟测试,尚未在真实课程上验证。整体统计、估算和静音回退也尚未完成所有真实课程和长时间后台端到端验证,不能保证最终学分或全部课程结构兼容。
|
||||
|
||||
前台视频加载90秒未就绪、启动20秒未推进或播放停滞60秒时,会尝试一次刷新恢复,每段视频在一次运行内共享该额度。临时播放中断有界重试;播放权限拒绝不进入刷新循环。前台视频结束25秒仍无平台完成确认会等待,后台窗口更长。这些恢复不会伪造完成状态。
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
[仓库首页](README.md) · [安装与使用](2倍速脚本使用说明.md)
|
||||
|
||||
## 1.4.1(2026-10-10)
|
||||
|
||||
- 新增“全过程静音”开关,默认关闭,可随时开启,立即静音当前课程播放器。
|
||||
- 切换视频、课程、重建播放器及网页恢复声音后继续保持静音;播放前应用静音。
|
||||
- 保存偏好,刷新后沿用;等待、后台及人工取消后仍保留静音设置。
|
||||
- 关闭开关恢复播放器原有静音状态,保持原来的音量和播放位置。
|
||||
- 增加10项本地行为测试,总计91项,更新使用说明。
|
||||
|
||||
## 1.4.0(2026-10-06)
|
||||
|
||||
- 新增整体完成进度条、已完成/总课程数、平均学习进度及待合格测验数量。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 学员中心 · 2倍速学习助手
|
||||
|
||||
用于 [学员中心课程页](https://icampus.yunyinsh.com/course/) 的 Tampermonkey(篡改猴/油猴)脚本。支持 **2倍速播放、自动续播、测验作答和交卷、按平台合格状态换课、后台播放,以及整体进度和完成时间估算**。
|
||||
用于 [学员中心课程页](https://icampus.yunyinsh.com/course/) 的 Tampermonkey(篡改猴/油猴)脚本。支持 **2倍速播放、全过程静音、自动续播、测验作答和交卷、按平台合格状态换课、后台播放,以及整体进度和完成时间估算**。
|
||||
|
||||
当前版本:**1.4.0**。普通使用只需要桌面 Chrome 和篡改猴,无需安装 Node.js、Git 或运行命令。
|
||||
当前版本:**1.4.1**。普通使用只需要桌面 Chrome 和篡改猴,无需安装 Node.js、Git 或运行命令。
|
||||
|
||||
**[获取脚本](icampus-study-helper.user.js) · [完整安装与使用说明](2倍速脚本使用说明.md) · [更新记录](CHANGELOG.md)**
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
2. 地址栏输入 `chrome://extensions`,找到 Tampermonkey →“详情”,按下表检查设置。
|
||||
3. 在仓库打开 [icampus-study-helper.user.js](icampus-study-helper.user.js),使用文件页面的“下载”或“原始文件 / Raw”保存完整脚本。
|
||||
4. 点击篡改猴图标 →“管理面板”→“实用工具”→“从文件导入 / Import from file”,选择下载的 `.user.js`,按提示安装。找不到该入口或不接受文件时,使用下方复制方法。
|
||||
5. 登录课程网站并刷新,确认左下角显示 **“2倍速学习助手 · 1.4.0”**。打开课程列表,展开目标分组,选择范围并点击“开始”。停止时点击“人工取消”。
|
||||
5. 登录课程网站并刷新,确认左下角显示 **“2倍速学习助手 · 1.4.1”**。打开课程列表,展开目标分组,选择范围并点击“开始”。停止时点击“人工取消”。
|
||||
|
||||
扩展应先在普通窗口安装,再按需允许在无痕窗口运行;参考 [Chrome 官方扩展帮助](https://support.google.com/chrome_webstore/answer/2664769?hl=en)。
|
||||
|
||||
@@ -47,9 +47,10 @@
|
||||
| 课程完成判断 | **学习进度100%+平台判定测验合格**才换课;无需测验的课程只核对学习完成 |
|
||||
| 持续启用 | 开始后保持启用,同一标签页刷新可接续;“人工取消”清除启用状态 |
|
||||
| 后台播放 | 默认开启“切标签页继续播放”,保留网站正常播放和保存流程 |
|
||||
| 自动播放恢复(新增) | 有声自动播放被拒绝时可尝试一次静音播放;仍失败则等待点击,避免定时空转 |
|
||||
| 整体进度(新增) | 完成百分比、已完成/总课程数、平均学习进度、待合格测验数 |
|
||||
| 时间预测(新增) | 显示预计剩余时长和完成日期时间;缺失时长用已知课程均值粗估并标明 |
|
||||
| 全过程静音(新增) | 勾选后立即静音,切换课程/视频和播放器重建后继续静音,刷新保留偏好 |
|
||||
| 自动播放恢复 | 有声自动播放被拒绝时可尝试一次静音播放;仍失败则等待点击,避免定时空转 |
|
||||
| 整体进度 | 完成百分比、已完成/总课程数、平均学习进度、待合格测验数 |
|
||||
| 时间预测 | 显示预计剩余时长和完成日期时间;缺失时长用已知课程均值粗估并标明 |
|
||||
| 范围与排错 | 按可见列表顺序继续、仅当前课程、只做本门测验;提供状态和运行记录 |
|
||||
|
||||
**学习100%、测验75%、平台显示已合格 → 继续下一课程。学习75%、测验已合格 → 继续视频。**
|
||||
@@ -69,6 +70,8 @@
|
||||
|
||||
默认勾选“自动测验,按平台合格线通过”“切标签页继续播放”和“播放受限时尝试静音”。需要有声播放时可关闭静音尝试;浏览器拒绝自动播放时手动点击网页播放按钮。静音成功后面板标注“静音”,可在播放器恢复声音。
|
||||
|
||||
需要全程无声音时,勾选新增的 **“全过程静音”**(默认关闭)。该开关独立于自动运行,在开始前、播放中、等待和后台均保持课程播放器静音;人工取消只停止自动运行,不关闭静音偏好。关闭静音开关后恢复播放器开启前的静音状态;原本已静音的播放器仍保持静音。普通与无痕环境分别保存偏好。
|
||||
|
||||
“已启动 · 等待恢复”仍是启用状态;普通等待会定期检查或随页面变化接续。不支持的题型、登录失效、自动播放仍受限等情况需人工处理。手动暂停视频后会等待手动恢复播放;更换范围前先“人工取消”。
|
||||
|
||||
**仓库推送不会自动更新浏览器已安装脚本。** 更新时重新导入最新文件,或在原脚本中替换全部代码;然后分别刷新实际使用的普通/无痕课程标签页并检查面板版本。只启用一份助手,停用旧的“学员中心 · 本地网络诊断”等测试脚本。
|
||||
@@ -83,7 +86,7 @@
|
||||
|
||||
## 验证与开发
|
||||
|
||||
目前 **81项本地行为测试**,覆盖播放、测验、恢复、后台、完成判断,以及新增统计和自动播放恢复。实际卡住页面已通过点击播放恢复2倍速,并重新收到平台保存确认。新版本的统计、预测和静音回退通过本地模拟测试,尚未完成所有课程及长期后台的真实端到端验证。
|
||||
目前 **91项本地行为测试**,覆盖播放、测验、恢复、后台、完成判断、统计、自动播放恢复和全过程静音。实际卡住页面此前已通过点击播放恢复2倍速,并重新收到平台保存确认。全过程静音新增10项本地测试,尚未在真实课程上验证;统计、预测和静音回退也尚未完成所有课程及长期后台的真实端到端验证。
|
||||
|
||||
开发验证需要 Node.js 18+,无需安装第三方依赖:
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
const assert=require('node:assert/strict');
|
||||
const {harness,ID}=require('./study-helper.test.cjs');
|
||||
const KEY='icampus.study.v1.fullMuted';
|
||||
let passed=0;
|
||||
async function test(name,fn){await fn();passed++;console.log(`PASS ${name}`);}
|
||||
function mount(h){const nodes=new Map();const panel={querySelector(s){if(!nodes.has(s))nodes.set(s,{textContent:'',value:'all',checked:true});return nodes.get(s);}};
|
||||
h.context.document.body={append(){}};h.context.document.createElement=()=>({style:{},attachShadow:()=>panel});h.api.mount();return nodes;}
|
||||
function toggle(ui,enabled){const checkbox=ui.get('[data-full-muted]');checkbox.checked=enabled;checkbox.onchange();}
|
||||
function soundEvent(h,type,video=h.video){h.context.document.dispatchEvent({type,target:video});}
|
||||
(async()=>{
|
||||
await test('full mute is opt-in and enabling it silences an already playing video immediately',()=>{
|
||||
const h=harness(),ui=mount(h);assert.equal(ui.get('[data-full-muted]').checked,false);
|
||||
h.video.muted=false;h.video.volume=0.7;toggle(ui,true);
|
||||
assert.equal(h.video.muted,true);assert.equal(h.video.defaultMuted,true);assert.equal(h.video.volume,0.7);
|
||||
assert.equal(h.storage.get(KEY),'on');assert.equal(h.video.pauseCount,0);assert.equal(h.video.playCount,0);
|
||||
});
|
||||
await test('full mute is applied before assistant playback, even with autoplay fallback disabled',async()=>{
|
||||
const h=harness(),ui=mount(h);toggle(ui,true);
|
||||
const fallback=ui.get('[data-muted]');fallback.checked=false;fallback.onchange();
|
||||
h.video.muted=false;let seen=false;h.video.play=function(){seen=this.muted;return Promise.resolve();};
|
||||
h.api.begin();await h.api.tickCourse(ID);h.advance(1000);await h.api.tickCourse(ID);
|
||||
assert.equal(seen,true);assert.equal(h.video.playbackRate,2);
|
||||
});
|
||||
await test('page volume resets are silenced again and unrelated media is left alone',()=>{
|
||||
const h=harness(),ui=mount(h);toggle(ui,true);h.video.muted=false;h.video.defaultMuted=false;
|
||||
soundEvent(h,'volumechange');assert.equal(h.video.muted,true);assert.equal(h.video.defaultMuted,true);
|
||||
const other={muted:false,matches:()=>false};soundEvent(h,'play',other);assert.equal(other.muted,false);
|
||||
});
|
||||
await test('replacement course player is muted on insertion before any start command',()=>{
|
||||
const h=harness(),ui=mount(h);toggle(ui,true);
|
||||
const next={muted:false,defaultMuted:false,isConnected:true};h.video.isConnected=false;
|
||||
h.selectors.set('video.player__video',next);h.mutate();assert.equal(next.muted,true);assert.equal(next.defaultMuted,true);
|
||||
});
|
||||
await test('source reload and native play events keep the current player silent',()=>{
|
||||
const h=harness(),ui=mount(h);toggle(ui,true);
|
||||
for(const type of ['loadstart','loadedmetadata','play']){h.video.muted=false;soundEvent(h,type);assert.equal(h.video.muted,true);}
|
||||
});
|
||||
await test('normal refresh restores the preference before playback',async()=>{
|
||||
const h=harness(),ui=mount(h);toggle(ui,true);
|
||||
const restarted=harness({storageEntries:[...h.storage]});const nextUI=mount(restarted);await restarted.api.tick();
|
||||
assert.equal(nextUI.get('[data-full-muted]').checked,true);assert.equal(restarted.video.muted,true);
|
||||
assert.equal(restarted.api.state.armed,false);
|
||||
});
|
||||
await test('turning off restores previous mute state without changing volume or position',()=>{
|
||||
const h=harness(),ui=mount(h);h.video.muted=false;h.video.defaultMuted=false;h.video.volume=0.5;
|
||||
const time=h.video.currentTime;toggle(ui,true);toggle(ui,false);
|
||||
assert.equal(h.video.muted,false);assert.equal(h.video.defaultMuted,false);assert.equal(h.video.volume,0.5);
|
||||
assert.equal(h.video.currentTime,time);assert.equal(h.storage.get(KEY),'off');
|
||||
soundEvent(h,'play');assert.equal(h.video.muted,false);
|
||||
});
|
||||
await test('turning off preserves a player that was already muted beforehand',()=>{
|
||||
const h=harness(),ui=mount(h);h.video.muted=true;h.video.defaultMuted=true;toggle(ui,true);toggle(ui,false);
|
||||
assert.equal(h.video.muted,true);assert.equal(h.video.defaultMuted,true);
|
||||
});
|
||||
await test('background, waiting and cancellation retain explicit full-mute preference',async()=>{
|
||||
const h=harness(),ui=mount(h);toggle(ui,true);h.api.begin();h.api.park('等待确认');
|
||||
h.context.document.visibilityState='hidden';h.video.muted=false;await h.api.tick();assert.equal(h.video.muted,true);
|
||||
h.api.stop();h.video.muted=false;soundEvent(h,'volumechange');assert.equal(h.video.muted,true);
|
||||
assert.equal(h.storage.get(KEY),'on');assert.equal(h.api.state.armed,false);
|
||||
});
|
||||
await test('enabling full mute retries a blocked playback without losing running intent',async()=>{
|
||||
const h=harness(),ui=mount(h);const fallback=ui.get('[data-muted]');fallback.checked=false;fallback.onchange();
|
||||
let calls=0;h.video.play=function(){calls++;return this.muted?Promise.resolve():Promise.reject({name:'NotAllowedError'});};
|
||||
h.api.begin();await h.api.tickCourse(ID);h.advance(1000);await h.api.tickCourse(ID);await Promise.resolve();
|
||||
assert.equal(h.api.state.running,false);assert.equal(calls,1);toggle(ui,true);
|
||||
await h.api.tick();h.advance(1000);await h.api.tick();assert.equal(h.api.state.running,true);assert.equal(calls,2);
|
||||
});
|
||||
console.log(`${passed} full-mute tests passed.`);
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -1,8 +1,8 @@
|
||||
// ==UserScript==
|
||||
// @name 学员中心 · 2倍速学习助手
|
||||
// @namespace local.icampus.study
|
||||
// @version 1.4.0
|
||||
// @description 支持切换标签页继续2倍速播放与保存进度,自动测验及错题重试。
|
||||
// @version 1.4.1
|
||||
// @description 支持全过程静音、切换标签页继续2倍速播放与保存进度,自动测验及错题重试。
|
||||
// @match https://icampus.yunyinsh.com/course/*
|
||||
// @run-at document-start
|
||||
// @grant none
|
||||
@@ -147,6 +147,10 @@
|
||||
const BACKGROUND_KEY = 'icampus.study.v1.background';
|
||||
const INTENT_KEY = 'icampus.study.v1.enabled';
|
||||
const MUTED_KEY = 'icampus.study.v1.mutedFallback';
|
||||
const FULL_MUTED_KEY = 'icampus.study.v1.fullMuted';
|
||||
let fullMutedEnabled = false;
|
||||
try { fullMutedEnabled = localStorage.getItem(FULL_MUTED_KEY) === 'on'; } catch {}
|
||||
const mutedPlayers = new Map();
|
||||
let mutedFallbackEnabled = true;
|
||||
try { mutedFallbackEnabled = localStorage.getItem(MUTED_KEY) !== 'off'; } catch {}
|
||||
let armed = false;
|
||||
@@ -191,6 +195,39 @@
|
||||
const logs = [];
|
||||
const visible = el => !!el && el.getClientRects().length > 0;
|
||||
const currentVideo = () => document.querySelector('video.player__video');
|
||||
function applyFullMute(video=currentVideo()) {
|
||||
if(!fullMutedEnabled || !video) return;
|
||||
if(!mutedPlayers.has(video)) mutedPlayers.set(video,{muted:!!video.muted,defaultMuted:!!video.defaultMuted});
|
||||
if(!video.defaultMuted) video.defaultMuted=true;
|
||||
if(!video.muted) video.muted=true;
|
||||
// Detached players cannot emit sound; bound the references across course changes.
|
||||
for(const player of mutedPlayers.keys()) if(player!==video && player.isConnected===false) mutedPlayers.delete(player);
|
||||
}
|
||||
function setFullMuted(enabled) {
|
||||
fullMutedEnabled=!!enabled;
|
||||
try { localStorage.setItem(FULL_MUTED_KEY,fullMutedEnabled?'on':'off'); } catch {}
|
||||
if(fullMutedEnabled) {
|
||||
applyFullMute();
|
||||
if(waiting) {waiting.passive=false;waiting.nextAt=0;}
|
||||
} else {
|
||||
for(const [video,previous] of mutedPlayers) {
|
||||
video.defaultMuted=previous.defaultMuted; video.muted=previous.muted;
|
||||
}
|
||||
mutedPlayers.clear();
|
||||
}
|
||||
log(fullMutedEnabled?'已开启全过程静音,切换课程和视频后继续保持静音。':'已关闭全过程静音,恢复播放器原来的静音状态。');
|
||||
render();
|
||||
}
|
||||
function onMediaSound(event) {
|
||||
const video=event.target;
|
||||
if(video && (video===currentVideo() || video.matches?.('video.player__video'))) applyFullMute(video);
|
||||
}
|
||||
for(const event of ['loadstart','loadedmetadata','play','volumechange']) document.addEventListener(event,onMediaSound,true);
|
||||
if(typeof MutationObserver!=='undefined') {
|
||||
const muteObserver=new MutationObserver(()=>applyFullMute());
|
||||
muteObserver.observe(document,{childList:true,subtree:true,attributes:true,attributeFilter:['class']});
|
||||
}
|
||||
applyFullMute();
|
||||
function log(message) {
|
||||
logs.push({time: new Date().toLocaleTimeString(), message: String(message).replace(/https?:\/\/\S+/g, '[链接]').slice(0, 300)});
|
||||
if (logs.length > 60) logs.shift();
|
||||
@@ -685,6 +722,7 @@
|
||||
}
|
||||
function attemptPlay(a, run=runNumber) {
|
||||
if(!running || active!==a || a.playPending || a.attempts>=3 || Date.now()<a.nextPlayAt) return;
|
||||
applyFullMute(a.video);
|
||||
a.playPending=true; ++a.attempts; a.nextPlayAt=Date.now()+2000;
|
||||
const failed=error => {
|
||||
if(!running || run!==runNumber || active!==a) return;
|
||||
@@ -695,7 +733,7 @@
|
||||
log('浏览器限制有声自动播放,尝试静音播放;可在播放器中恢复声音。');
|
||||
return attemptPlay(a,run);
|
||||
}
|
||||
if(a.mutedFallbackTried) a.video.muted=false;
|
||||
if(a.mutedFallbackTried && !fullMutedEnabled) a.video.muted=false;
|
||||
return park('浏览器阻止自动播放,请手动点击网页“点击播放”,助手会自动接续;刷新不能保证解除此限制。',{passive:true});
|
||||
}
|
||||
if(error?.name!=='AbortError') recoverMedia(a.courseId,a.id,'播放启动失败');
|
||||
@@ -732,6 +770,7 @@
|
||||
return park(`超过${saveTimeout/1000}秒未收到平台保存确认,已暂停,避免继续播放却未计入进度。`);
|
||||
}
|
||||
async function tick() {
|
||||
applyFullMute();
|
||||
if(busy || !armed) return;
|
||||
if(!running) {
|
||||
if(!waiting) return;
|
||||
@@ -765,6 +804,7 @@
|
||||
}
|
||||
function render() {
|
||||
if (!panel) return;
|
||||
panel.querySelector('[data-full-muted]').checked=fullMutedEnabled;
|
||||
panel.querySelector('[data-status]').textContent = status;
|
||||
const v = active?.video;
|
||||
const course = details.get(route(location.hash).id)?.course;
|
||||
@@ -807,13 +847,14 @@
|
||||
host.style.maxHeight='calc(100vh - 32px)'; host.style.overflowY='auto';
|
||||
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.4.0</strong><p data-status></p><small data-progress></small>
|
||||
<section><strong>2倍速学习助手 · 1.4.1</strong><p data-status></p><small data-progress></small>
|
||||
<p data-overall></p><progress data-overall-bar aria-label="整体课程完成百分比" max="100" value="0" style="width:100%"></progress>
|
||||
<p data-estimate style="font-size:12px;color:#b7c7df"></p>
|
||||
<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>
|
||||
<div><label><input type="checkbox" data-full-muted> 全过程静音</label></div>
|
||||
<div><label><input type="checkbox" data-muted> 播放受限时尝试静音</label></div>
|
||||
<small data-background-state></small>
|
||||
<div><button data-quiz-only>只做本门测验</button></div>
|
||||
@@ -824,6 +865,9 @@
|
||||
const backgroundToggle=panel.querySelector('[data-background]');
|
||||
backgroundToggle.checked=backgroundEnabled;
|
||||
backgroundToggle.onchange=()=>setBackgroundEnabled(backgroundToggle.checked);
|
||||
const fullMutedToggle=panel.querySelector('[data-full-muted]');
|
||||
fullMutedToggle.checked=fullMutedEnabled;
|
||||
fullMutedToggle.onchange=()=>setFullMuted(fullMutedToggle.checked);
|
||||
const mutedToggle=panel.querySelector('[data-muted]');
|
||||
mutedToggle.checked=mutedFallbackEnabled;
|
||||
mutedToggle.onchange=()=>{
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "e-learning-assistant",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"private": true,
|
||||
"description": "学员中心2倍速播放和测验辅助脚本",
|
||||
"scripts": {
|
||||
"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 && node persistent-run.test.cjs && node overall-progress.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 && node persistent-run.test.cjs && node overall-progress.test.cjs && node full-mute.test.cjs"
|
||||
},
|
||||
"engines": { "node": ">=18" }
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ function detail(overrides = {}) {
|
||||
{id: RID, name: '视频一', type: 'VIDEO', sortNo: 1, completed: false, playable: true},
|
||||
{id: '2093583972926885888', name: '视频二', type: 'VIDEO', sortNo: 2, completed: false, playable: true}], ...overrides};
|
||||
}
|
||||
function harness({sessionEntries=[]}={}) {
|
||||
function harness({sessionEntries=[],storageEntries=[]}={}) {
|
||||
let now = 100000;
|
||||
class TestDate extends Date { static now() { return now; } }
|
||||
const storage = new Map(), session = new Map(sessionEntries), selectors = new Map();
|
||||
const storage = new Map(storageEntries), session = new Map(sessionEntries), selectors = new Map();
|
||||
const mutations=[];
|
||||
class TestMutationObserver {constructor(callback){mutations.push(callback);}observe(){}}
|
||||
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);},
|
||||
@@ -35,7 +37,7 @@ function harness({sessionEntries=[]}={}) {
|
||||
window.self = window.top = window;
|
||||
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;}},
|
||||
URL, URLSearchParams, Date: TestDate, crypto: {randomUUID: () => 'test-owner'}, TextDecoder,Event:TestEvent,
|
||||
URL, URLSearchParams, Date: TestDate, crypto: {randomUUID: () => 'test-owner'}, TextDecoder,Event:TestEvent,MutationObserver:TestMutationObserver,
|
||||
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)},
|
||||
setInterval() {}};
|
||||
@@ -59,7 +61,7 @@ function harness({sessionEntries=[]}={}) {
|
||||
classList: {contains: () => i === 0}, querySelector: () => ({textContent: name}), click() { ++this.clicks; }}));
|
||||
selectors.set('button.resource-item', buttons);
|
||||
api.receive({id: ID, tail: ''}, {bizCode: 0, data: detail()}, 200);
|
||||
return {api, video, buttons, context, window, pending, selectors, storage, session, advance(ms) { now += ms; }};
|
||||
return {api, video, buttons, context, window, pending, selectors, storage, session, mutate(){for(const callback of mutations)callback();}, advance(ms) { now += ms; }};
|
||||
}
|
||||
module.exports = {harness, detail, ID, RID};
|
||||
if (require.main === module) (async () => {
|
||||
|
||||
Reference in New Issue
Block a user