diff --git a/2倍速脚本使用说明.md b/2倍速脚本使用说明.md index 0dea4aa..bcf8986 100644 --- a/2倍速脚本使用说明.md +++ b/2倍速脚本使用说明.md @@ -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秒仍无平台完成确认会等待,后台窗口更长。这些恢复不会伪造完成状态。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cf689b..68afb41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ [仓库首页](README.md) · [安装与使用](2倍速脚本使用说明.md) +## 1.4.1(2026-10-10) + +- 新增“全过程静音”开关,默认关闭,可随时开启,立即静音当前课程播放器。 +- 切换视频、课程、重建播放器及网页恢复声音后继续保持静音;播放前应用静音。 +- 保存偏好,刷新后沿用;等待、后台及人工取消后仍保留静音设置。 +- 关闭开关恢复播放器原有静音状态,保持原来的音量和播放位置。 +- 增加10项本地行为测试,总计91项,更新使用说明。 + ## 1.4.0(2026-10-06) - 新增整体完成进度条、已完成/总课程数、平均学习进度及待合格测验数量。 diff --git a/README.md b/README.md index 17675ce..6894b5b 100644 --- a/README.md +++ b/README.md @@ -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+,无需安装第三方依赖: diff --git a/full-mute.test.cjs b/full-mute.test.cjs new file mode 100644 index 0000000..ecab2b3 --- /dev/null +++ b/full-mute.test.cjs @@ -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;}); diff --git a/icampus-study-helper.user.js b/icampus-study-helper.user.js index a1b11f0..47e3958 100644 --- a/icampus-study-helper.user.js +++ b/icampus-study-helper.user.js @@ -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() { 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 = ` -
2倍速学习助手 · 1.4.0

+
2倍速学习助手 · 1.4.1

+
@@ -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=()=>{ diff --git a/package.json b/package.json index 792bcf6..2e3ae52 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/study-helper.test.cjs b/study-helper.test.cjs index 4b69003..3106703 100644 --- a/study-helper.test.cjs +++ b/study-helper.test.cjs @@ -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 () => {