Add overall progress and completion estimates with autoplay recovery
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user