Add overall progress and completion estimates with autoplay recovery

This commit is contained in:
2026-10-06 03:01:32 +08:00
parent d80c490f48
commit 8023a802f1
8 changed files with 493 additions and 78 deletions
+94 -6
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name 学员中心 · 2倍速学习助手
// @namespace local.icampus.study
// @version 1.3.1
// @version 1.4.0
// @description 支持切换标签页继续2倍速播放与保存进度,自动测验及错题重试。
// @match https://icampus.yunyinsh.com/course/*
// @run-at document-start
@@ -35,6 +35,44 @@
const learningDone=hasProgress ? Number(c.progressPercent)>=100 : c.status==='COMPLETED' || !!c.completedAt;
return learningDone && (!c.examRequired || examPassed(c));
}
function learningPercent(c) {
const n=c?.progressPercent;
if(n!==null && n!==undefined && n!=='' && Number.isFinite(Number(n))) return Math.max(0,Math.min(100,Number(n)));
return c?.status==='COMPLETED' || c?.completedAt ? 100 : 0;
}
// Estimates never participate in course completion or navigation decisions.
function overallProgress(courses, detailMap, speed=2) {
const unique=[...new Map(courses.map(c=>[c.id,c])).values()];
const items=unique.map(c=>({course:detailMap.get(c.id)?.course || c,detail:detailMap.get(c.id)}));
const durations=items.map(({detail})=>{
const resources=detail?.resources;
if(!resources?.length || resources.some(r=>r.type!=='VIDEO' || !Number.isFinite(Number(r.durationSeconds)) || Number(r.durationSeconds)<=0)) return null;
return resources.reduce((sum,r)=>sum+Number(r.durationSeconds),0);
});
const samples=durations.filter(n=>n!==null);
const average=samples.length?samples.reduce((a,b)=>a+b,0)/samples.length:null;
let done=0,learning=0,quizzes=0,remaining=0,estimated=0,unknown=0,manual=0;
items.forEach(({course:c,detail},i)=>{
learning+=learningPercent(c);
if(courseDone(c)) {done++;return;}
if(c.examRequired && !examPassed(c)) quizzes++;
if(detail?.resources.some(r=>r.type!=='VIDEO' && r.completed!==true)) manual++;
if(learningPercent(c)>=100) return;
if(durations[i]!==null) {
remaining+=detail.resources.reduce((sum,r)=>sum+(r.completed===true?0:Math.max(0,Number(r.durationSeconds)-Math.max(0,Number(r.currentPositionSeconds)||0))),0);
} else if(average!==null) {remaining+=average*(1-learningPercent(c)/100);estimated++;}
else unknown++;
});
const total=items.length, pending=total-done;
// Two minutes per pending exam and 15 seconds per course are explicit planning allowances.
const seconds=total && !unknown && !manual ? Math.ceil(remaining/(speed>0?speed:2)+quizzes*120+pending*15) : null;
return {total,done,percent:total?done/total*100:0,learningPercent:total?learning/total:0,
quizzes,seconds,estimated,unknown,manual,samples:samples.length};
}
function durationText(seconds) {
const minutes=Math.ceil(Math.max(0,seconds)/60);
return minutes>=60?`${Math.floor(minutes/60)}小时${minutes%60}分`:`${minutes}分钟`;
}
function videoPlan(detail) {
if (!detail?.course || !Array.isArray(detail.resources)) return {kind: 'wait'};
const resources = [...detail.resources].sort((a, b) => a.sortNo - b.sortNo);
@@ -98,7 +136,7 @@
} catch { return null; }
}
const core = {normalize, route, endpoint, bodyJSON, courseDone, examPassed, videoPlan, resolveCards, leaseAvailable,
questionKey, selectionKey, chooseAnswer, learnCard, quizEndpoint};
questionKey, selectionKey, chooseAnswer, learnCard, quizEndpoint, learningPercent, overallProgress, durationText};
if (typeof module === 'object' && module.exports && typeof window === 'undefined') { module.exports = core; return; }
if (window.top !== window.self || document.getElementById('icampus-study-helper')) return;
@@ -108,6 +146,9 @@
const RESUME_KEY = 'icampus.study.v1.recovery';
const BACKGROUND_KEY = 'icampus.study.v1.background';
const INTENT_KEY = 'icampus.study.v1.enabled';
const MUTED_KEY = 'icampus.study.v1.mutedFallback';
let mutedFallbackEnabled = true;
try { mutedFallbackEnabled = localStorage.getItem(MUTED_KEY) !== 'off'; } catch {}
let armed = false;
let waiting = null;
let backgroundEnabled = true;
@@ -237,7 +278,10 @@
const data = response.data;
if (meta.id === 'packages' && Array.isArray(data)) {
courses = data.flatMap(p => Array.isArray(p.courses) ? p.courses : []).map(safeCourse).filter(Boolean);
packagesAt = Date.now(); return;
packagesAt = Date.now();
// A fresh list is authoritative; old details must not override newer list flags.
for(const c of courses) { const d=details.get(c.id); if(d) d.course={...d.course,...c}; }
render(); return;
}
if (!meta.tail && data?.course && Array.isArray(data.resources)) {
const course = safeCourse(data.course);
@@ -250,6 +294,7 @@
const detail = details.get(meta.id);
const resource = detail?.resources.find(r => r.id === resourceId);
if (resource) {
if (Number.isFinite(data.currentPositionSeconds)) resource.currentPositionSeconds=data.currentPositionSeconds;
if (typeof data.resourceCompleted === 'boolean') resource.completed = data.resourceCompleted;
if (typeof data.courseStatus === 'string') detail.course.status = data.courseStatus;
if (typeof data.courseProgressPercent === 'number') detail.course.progressPercent = data.courseProgressPercent;
@@ -644,7 +689,15 @@
const failed=error => {
if(!running || run!==runNumber || active!==a) return;
a.playPending=false;
if(error?.name==='NotAllowedError') return park('浏览器阻止自动播放,请手动点击网页“点击播放”,助手会自动接续。');
if(error?.name==='NotAllowedError') {
if(mutedFallbackEnabled && !a.mutedFallbackTried && !a.video.muted) {
a.mutedFallbackTried=true; a.video.muted=true; a.nextPlayAt=0; a.attempts=Math.min(a.attempts,2);
log('浏览器限制有声自动播放,尝试静音播放;可在播放器中恢复声音。');
return attemptPlay(a,run);
}
if(a.mutedFallbackTried) a.video.muted=false;
return park('浏览器阻止自动播放,请手动点击网页“点击播放”,助手会自动接续;刷新不能保证解除此限制。',{passive:true});
}
if(error?.name!=='AbortError') recoverMedia(a.courseId,a.id,'播放启动失败');
// AbortError commonly means the website is replacing the media source.
};
@@ -716,8 +769,9 @@
const v = active?.video;
const course = details.get(route(location.hash).id)?.course;
panel.querySelector('[data-progress]').textContent = v
? `实际 ${v.playbackRate.toFixed(1)}倍 · ${Math.floor(v.currentTime / 60)}:${String(Math.floor(v.currentTime % 60)).padStart(2, '0')} / ${Math.floor(v.duration / 60)}:${String(Math.floor(v.duration % 60)).padStart(2, '0')}\n平台进度 ${course?.progressPercent ?? '待确认'}% · ${active.saved ? '已收到保存确认' : '等待保存确认'}`
? `实际 ${v.playbackRate.toFixed(1)}倍${v.muted?' · 静音':''} · ${Math.floor(v.currentTime / 60)}:${String(Math.floor(v.currentTime % 60)).padStart(2, '0')} / ${Math.floor(v.duration / 60)}:${String(Math.floor(v.duration % 60)).padStart(2, '0')}\n平台进度 ${course?.progressPercent ?? '待确认'}% · ${active.saved ? '已收到保存确认' : '等待保存确认'}`
: '以平台保存结果为准;电脑需保持唤醒。';
renderOverall();
panel.querySelector('[data-start]').disabled = armed;
panel.querySelector('[data-start]').textContent = armed ? (running?'已启动 · 运行中':'已启动 · 等待恢复') : '开始';
panel.querySelector('[data-quiz-only]').disabled = armed;
@@ -725,17 +779,42 @@
? `${isHidden()?'后台':'前台'} · 切标签页继续播放已开启` : '后台模式已关闭';
mode.disabled = armed;
}
function renderOverall() {
const current=details.get(route(location.hash).id)?.course;
const single=mode?.value==='current' || quizOnly;
const list=single?(current?[current]:[]):courses.length?courses:current?[current]:[];
const summary=overallProgress(list,details);
const scope=single?'当前课程':courses.length?'已读取课程列表':'未读取列表,暂统计当前课程';
panel.querySelector('[data-overall]').textContent=summary.total
? `${scope}\n整体完成 ${summary.percent.toFixed(1)}% · ${summary.done}/${summary.total}门\n平均学习进度 ${summary.learningPercent.toFixed(1)}% · 待合格测验 ${summary.quizzes}门`
: '整体进度:等待课程数据';
panel.querySelector('[data-overall-bar]').value=summary.percent;
let estimate='预计剩余:等待视频时长数据\n预计完成:暂无法估算';
if(summary.manual) estimate='预计剩余/完成:存在需人工处理的学习资源';
else if(quizOnly) estimate='仅测验模式:重考次数不确定,暂不预测完成时间';
else if(summary.total && summary.done===summary.total) estimate='统计范围内已完成\n预计剩余:0分钟';
else if(summary.seconds!==null) {
const approximate=summary.estimated?`粗估(${summary.estimated}门时长按已知课程均值)`:'估算';
const finish=running?new Date(Date.now()+summary.seconds*1000).toLocaleString('zh-CN',{hour12:false}):'等待开始或恢复后计算';
estimate=`预计剩余:约${durationText(summary.seconds)} · ${approximate}\n预计完成:${finish}\n按2倍速,另预留每门测验2分钟、换课15秒;重考和停顿会延后。`;
}
panel.querySelector('[data-estimate]').textContent=estimate;
}
function mount() {
if (!document.body) return;
const host = document.createElement('div'); host.id = 'icampus-study-helper';
host.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:2147483646';
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.3.1</strong><p data-status></p><small data-progress></small>
<section><strong>2倍速学习助手 · 1.4.0</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-muted> 播放受限时尝试静音</label></div>
<small data-background-state></small>
<div><button data-quiz-only>只做本门测验</button></div>
<small>学习进度100%且测验合格后换课。持续运行,人工取消才关闭。</small>
@@ -745,6 +824,15 @@
const backgroundToggle=panel.querySelector('[data-background]');
backgroundToggle.checked=backgroundEnabled;
backgroundToggle.onchange=()=>setBackgroundEnabled(backgroundToggle.checked);
const mutedToggle=panel.querySelector('[data-muted]');
mutedToggle.checked=mutedFallbackEnabled;
mutedToggle.onchange=()=>{
mutedFallbackEnabled=mutedToggle.checked;
try { localStorage.setItem(MUTED_KEY,mutedFallbackEnabled?'on':'off'); } catch {}
if(waiting && mutedFallbackEnabled) {waiting.passive=false;waiting.nextAt=0;}
render();
};
mode.onchange=render;
function beginRun(only, resume) {
if (only && !route(location.hash).id) return show('请先进入需要作答的课程学习页。');
if (only) currentVideo()?.pause();