// ==UserScript==
// @name 小雅视频连播助手
// @namespace local.xiaoya.video
// @version 0.10.1
// @description 视频与自主观看资料连续学习,显示历史学习时长,自动缓存课程列表。
// @match https://whut.ai-augmented.com/*
// @match https://infra.ai-augmented.com/app/auth/*
// @run-at document-end
// @grant none
// ==/UserScript==
(() => {
'use strict';
const VERSION = '0.10.1';
const collator = new Intl.Collator('zh-CN', { numeric: true });
const isVideo = node => Number(node.type) === 9 ||
/^video\//i.test(node.mimetype || '') ||
/\.(mp4|webm|m4v|mov|mkv|avi|flv|m3u8)$/i.test(node.name || '');
const isSelfStudy = node => Number(node.task_type ?? node.task?.task_type ?? node.property?.task_type) === 1 ||
[node.tag, node.task_label, node.task?._taskTypeText].some(label => label === '自主观看');
const isStudyResource = node => Number(node.type) !== 1 && (isVideo(node) || isSelfStudy(node));
const resourceKind = node => isVideo(node) ? '视频' : /\.pptx?$/i.test(node.name || '') ? 'PPT' : '资料';
const DOCUMENT_MS = 120000, QUEUE_TTL = 10 * 60000, STATUS_TTL = 30000, HISTORY_TTL = 15 * 60000;
function validMinutes(value) {
const number = Number(value);
return Number.isInteger(number) && number >= 2 && number <= 1440;
}
function normalizeSettings(value = {}) {
const resourceMinutes = {};
for (const [id, minutes] of Object.entries(value?.resourceMinutes || {})) {
if (validMinutes(minutes)) resourceMinutes[id] = Number(minutes);
}
return { mode: value?.mode === 'single' ? 'single' : 'continuous',
documentMinutes: validMinutes(value?.documentMinutes) ? Number(value.documentMinutes) : 2,
skipCompleted: value?.skipCompleted === true, resourceMinutes };
}
// 接口返回平铺目录。优先使用平台 sort_position,缺失时按名称自然排序。
function compareNodes(a, b) {
if (a.sort_position != null && b.sort_position != null &&
Number.isFinite(Number(a.sort_position)) && Number.isFinite(Number(b.sort_position))) {
const order = Number(a.sort_position) - Number(b.sort_position);
if (order) return order;
}
return collator.compare(a.name || '', b.name || '');
}
function buildQueue(resources) {
const nodes = resources.filter(node => node && node.id != null && Number(node.del) !== 2);
const ids = new Set(nodes.map(node => String(node.id)));
const children = new Map();
for (const node of nodes) {
const parent = String(node.parent_id ?? '');
if (!children.has(parent)) children.set(parent, []);
children.get(parent).push(node);
}
for (const siblings of children.values()) {
siblings.sort(compareNodes);
}
const queue = [], seen = new Set();
function visit(node) {
const id = String(node.id);
if (seen.has(id)) return;
seen.add(id);
if (isStudyResource(node)) queue.push(node);
for (const child of children.get(id) || []) visit(child);
}
nodes.filter(node => !ids.has(String(node.parent_id ?? '')))
.sort(compareNodes).forEach(visit);
return queue;
}
function completionState(node, data = {}) {
const hasTask = !!(node.task_id || node.task?.task_id);
const finished = data.finished === true || data.finished === 1 || data.finished === '1' || data.finished === 'true'
? true : data.finished === false || data.finished === 0 || data.finished === '0' || data.finished === 'false' ? false : null;
const record = data.watchRecord || (!hasTask ? data : null);
const duration = Number(record?.duration), watched = Number(record?.watched_duration);
const percent = Number.isFinite(duration) && duration > 0 && Number.isFinite(watched) && watched >= 0
? Math.min(100, Math.ceil(watched / (duration * 0.95) * 100)) : null;
return { complete: hasTask ? finished : percent == null ? null : percent >= 100,
percent, hasTask, error: false, checkedAt: 0 };
}
function completionLabel(state) {
if (!state) return '待检测';
if (state.error) return '检测失败';
if (state.complete === true) return '已完成';
if (state.hasTask && state.complete === false && state.percent === 100) return '待提交';
if (state.percent > 0) return `已看 ${state.percent}%`;
return state.complete === false ? '未完成' : '待检测';
}
function resourcePath(course, node) {
const parent = node.parent_id == null || node.parent_id === '' ? '_' : String(node.parent_id);
return `/app/jx-web/mycourse/${encodeURIComponent(course)}/resource/${encodeURIComponent(parent)}/${encodeURIComponent(String(node.id))}`;
}
function nextVideo(nodes, currentId, states, skipCompleted) {
const index = nodes.findIndex(node => String(node.id) === currentId);
return index < 0 ? null : nodes.slice(index + 1).find(node =>
!skipCompleted || states.get(String(node.id))?.complete !== true) || null;
}
// 允许在 Node 中检查目录处理逻辑,不执行浏览器代码。
if (typeof module !== 'undefined' && module.exports && typeof window === 'undefined') {
module.exports = { buildQueue, isVideo, isSelfStudy, isStudyResource, completionState, completionLabel, resourcePath, nextVideo, normalizeSettings };
return;
}
if (window.top !== window.self || document.getElementById('xy-video-helper')) return;
const loginSurface = location.hostname === 'infra.ai-augmented.com' && location.pathname.startsWith('/app/auth/');
let loginNoticeShown = false;
const storageKey = 'xy-video-helper-v1';
let saved = {};
try { saved = JSON.parse(sessionStorage.getItem(storageKey) || '{}'); } catch { /* 使用默认值 */ }
let preferences = {};
try { preferences = JSON.parse(localStorage.getItem('xy-learning-preferences-v1') || '{}'); } catch { /* 使用默认值 */ }
let activeTab = !loginSurface && ['learning', 'list', 'settings'].includes(saved.activeTab) ? saved.activeTab : 'learning';
let queueSearch = '', queueFilter = 'all';
let activeCourse = saved.course || '';
let running = saved.running === true;
let muted = (preferences.muted ?? saved.muted) !== false;
let background = (preferences.background ?? saved.background) !== false;
let skipCompleted = saved.skipCompleted === true;
let collapsed = loginSurface ? false : saved.collapsed !== false;
let position = preferences.position || saved.position || null;
let userPaused = saved.userPaused === true;
let navigation = saved.navigation && Date.now() - saved.navigation.startedAt < 30000 ? saved.navigation : null;
let outgoingVideo = null, outgoingSource = '';
let queue = [], queueCourse = '', routeKey = '', generation = 0;
let video = null, endedListener = null, readyListener = null, pauseListener = null, playListener = null;
let nextTimer = null, loading = false, resumeAfterDialog = false;
let lastDialogClick = 0;
let backgroundIntent = false, backgroundTimer = null, backgroundAttempts = 0;
let lastBackgroundAttempt = 0, playPending = false, waitingForVideoReady = false;
const completions = new Map(), pendingChecks = new Map();
let completionEpoch = 0, completionBusy = false, currentCheckAt = 0;
let observedCourse = null, courseEpoch = 0, directoryCheckedAt = 0, cacheAt = 0;
let documentClock = null, advancing = false;
let history = { minutes: null, fetchedAt: 0, error: false }, historyBusy = false;
let auth = { state: 'checking', id: null, message: '', checkedAt: 0 }, authBusy = false, authEpoch = 0;
let courseInfo = { name: '', state: 'loading', fetchedAt: 0 }, courseInfoBusy = false;
let settings = normalizeSettings(), sessionEnded = false, resourceFailure = '', directoryIssue = '';
function tokenEntry() {
if (loginSurface) return undefined;
return document.cookie.split(';').map(item => item.trim()).find(item => item.split('=')[0].includes('prd-access-token'));
}
function tokenScope() {
const token = tokenEntry();
if (!token) return null;
let hash = 2166136261;
for (const char of token) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
return (hash >>> 0).toString(16);
}
let cacheScope = tokenScope();
const settingsKey = course => auth.id != null && course ? `xy-learning-settings-v1:${encodeURIComponent(String(auth.id))}:${course}` : null;
function loadSettings(course) {
try { settings = normalizeSettings(JSON.parse(localStorage.getItem(settingsKey(course)) || '{}')); }
catch { settings = normalizeSettings(); }
skipCompleted = settings.skipCompleted;
syncSettingsInputs();
}
function saveSettings() {
const key = settingsKey(route()?.course);
if (key) try { localStorage.setItem(key, JSON.stringify(settings)); } catch { /* 本页设置仍有效 */ }
persist();
}
function targetDocumentMs(node = currentNode()) {
return (settings.resourceMinutes[String(node?.id)] ?? settings.documentMinutes) * 60000;
}
const cacheKey = course => cacheScope ? `xy-learning-cache-v5:${cacheScope}:${course}` : null;
function readCache(course) {
try {
const key = cacheKey(course);
if (!key) return null;
const value = JSON.parse(localStorage.getItem(key) || 'null');
return value?.version === 1 && value.course === course && Number.isFinite(value.cachedAt) &&
Array.isArray(value.queue) && value.queue.every(node => node && node.id != null) &&
(value.completions == null || Array.isArray(value.completions)) ? value : null;
} catch { return null; }
}
function writeCache() {
const key = cacheKey(queueCourse);
if (!key || !queueCourse || !cacheAt) return;
try {
const previous = readCache(queueCourse);
localStorage.setItem(key, JSON.stringify({ version: 1, course: queueCourse, cachedAt: cacheAt,
queue, completions: [...completions], courseInfo: courseInfo.name ? courseInfo : previous?.courseInfo || null,
history: history.minutes != null ? history : previous?.history || null }));
} catch { /* 缓存不可用时仍可学习 */ }
}
const panel = document.createElement('section');
panel.id = 'xy-video-helper';
panel.style.cssText = 'all:initial;position:fixed;width:48px;height:48px;z-index:1100;color-scheme:light;';
const ui = panel.attachShadow({ mode: 'open' });
const icons = {
assistant: '',
play: '',
pause: '',
previous: '',
next: '',
settings: '',
loading: '',
stop: '',
collapse: '',
expand: '',
refresh: '',
};
ui.innerHTML = `
${icons.assistant}
学习助手未开始
当前课程正在确认登录状态…
${icons.play}
请先登录登录完成后,助手会自动识别课程。
当前任务— / —
视频待检测
准备好,开始这一节
已学习时长—历史统计 · 次日更新
完成状态以平台记录为准
`;
document.body.appendChild(panel);
const find = selector => ui.querySelector(selector);
const shell = find('.shell');
const launcher = find('[data-launcher]');
const status = find('[data-status]');
const list = find('[data-list]');
const muteInput = find('[data-mute]');
const backgroundInput = find('[data-background]');
const skipInput = find('[data-skip]');
const startButton = find('[data-start]');
const stopButton = find('[data-stop]');
const collapseButton = find('[data-collapse]');
muteInput.checked = muted;
backgroundInput.checked = background;
skipInput.checked = skipCompleted;
function contextMessage() {
if (auth.state === 'checking') return '正在确认登录状态…';
if (auth.state === 'signedOut') return auth.message || '请先登录';
if (auth.state === 'error') return '无法确认登录状态,请检查网络并点击刷新重试。';
if (!route()) return '请选择课程';
if (courseInfo.state === 'loading') return '正在读取课程信息…';
if (courseInfo.state === 'forbidden') return '当前账号无权访问此课程。';
if (courseInfo.state === 'error') return '课程信息读取失败,请点击刷新重试。';
if (directoryIssue && !queue.length) return directoryIssue;
if (!route()?.node) return loading ? '正在检测本课程的学习资料…' : settings.mode === 'single' ? '请选择学习资料' : '请选择学习资料,或点击“开始连续学习”。';
return '';
}
function report(message) {
status.textContent = contextMessage() || (navigation ? `跳转中:${navigation.name || '正在打开视频'}` : message);
updateNotice();
}
function updateNotice() {
const message = status.textContent || '';
const attention = /失败|无权|过期|超时|待确认|手动点击|未找到|未确认/.test(message);
status.hidden = !message || !attention && (['playing', 'reading', 'paused'].includes(viewMode()) || auth.state === 'signedOut' || !route());
status.dataset.tone = /失败|无权|超时/.test(message) ? 'error' : attention ? 'warning' : 'info';
}
function persist() {
try { sessionStorage.setItem(storageKey, JSON.stringify({ course: activeCourse, accountId: auth.id, running, userPaused, navigation, muted, background, skipCompleted, collapsed, position, activeTab })); } catch { /* 页面仍可使用 */ }
try { localStorage.setItem('xy-learning-preferences-v1', JSON.stringify({ muted, background, position })); } catch { /* 本页偏好仍有效 */ }
}
function formatTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
const whole = Math.floor(seconds), minutes = Math.floor(whole / 60);
return `${String(minutes).padStart(2, '0')}:${String(whole % 60).padStart(2, '0')}`;
}
function viewMode() {
if (auth.state === 'checking') return 'authChecking';
if (auth.state === 'signedOut') return 'signedOut';
if (auth.state === 'error') return 'authError';
if (!route()) return 'selectCourse';
if (courseInfo.state === 'loading') return 'courseLoading';
if (['error', 'forbidden'].includes(courseInfo.state)) return 'courseError';
if (navigation) return 'jumping';
const node = currentNode();
if (sessionEnded) return 'ended';
if (!node && settings.mode === 'single' && !loading) return 'selectResource';
if (running && (nextTimer || advancing || video?.ended)) return 'next';
if (loading && !queue.length) return 'loading';
if (node && !isVideo(node)) {
if (running) return documentClock?.ready ? 'reading' : 'loading';
return userPaused ? 'paused' : 'stopped';
}
if (running && (!video || video.readyState < 2 || playPending)) return 'loading';
if (video && !video.paused && !video.ended) return 'playing';
if (running || userPaused) return 'paused';
return 'stopped';
}
function setIcon(element, name) {
if (element.dataset.icon === name) return;
element.dataset.icon = name;
element.innerHTML = icons[name];
}
function updateView() {
const current = route();
const index = queue.findIndex(node => String(node.id) === current?.node);
const node = queue[index];
const timed = !!node && !isVideo(node);
const confirmed = auth.state === 'ready' && !!current;
find('[data-course-name]').textContent = auth.state === 'checking' ? '正在确认登录状态…'
: auth.state === 'signedOut' ? '请先登录' : auth.state === 'error' ? '登录状态未确认'
: !current ? '请选择课程' : courseInfo.name || (courseInfo.state === 'loading' ? '正在读取课程…' : '课程信息读取失败');
find('[data-course-name]').title = find('[data-course-name]').textContent;
find('[data-course-header]').hidden = !confirmed;
find('[data-tabs]').hidden = !confirmed;
find('[data-footer]').hidden = !confirmed;
find('[data-study-content]').hidden = !confirmed;
find('[data-empty-state]').hidden = confirmed;
if (!confirmed) {
find('[data-empty-title]').textContent = auth.state === 'checking' ? '正在确认登录状态' : auth.state === 'signedOut' ? '请先登录' : auth.state === 'error' ? '连接暂时不可用' : '请选择课程';
find('[data-empty-description]').textContent = auth.state === 'signedOut' ? '登录完成后,助手会自动识别课程。' : auth.state === 'error' ? '检查网络连接后,点击重新检测。' : auth.state === 'checking' ? '请稍候,正在连接学习平台。' : '进入课程后,自动检测视频和自主观看资料。';
find('[data-empty-retry]').hidden = !['error', 'signedOut'].includes(auth.state);
if (activeTab !== 'learning') setTab('learning', false);
}
find('[data-title]').textContent = navigation?.name || node?.name || (confirmed ? '请选择学习资料' : auth.state === 'signedOut' ? '请先登录' : '请选择课程');
find('[data-kind]').textContent = node ? timed ? `${resourceKind(node)} · 停留 ${targetDocumentMs(node) / 60000} 分钟` : '视频' : '待选择';
find('[data-index]').textContent = index >= 0 ? `${String(index + 1).padStart(2, '0')} / ${String(queue.length).padStart(2, '0')}` : '— / —';
const elapsed = timed ? (documentClock?.elapsedMs || 0) / 1000 : video?.currentTime;
const duration = timed ? targetDocumentMs(node) / 1000 : video?.duration;
find('[data-elapsed]').textContent = formatTime(elapsed);
find('[data-duration]').textContent = formatTime(duration);
find('[data-progress]').value = Number.isFinite(duration) && duration > 0 ? Math.min(100, Math.max(0, elapsed / duration * 100)) : 0;
find('[data-percent]').textContent = `${Math.floor(find('[data-progress]').value)}%`;
find('[data-timeline]').hidden = !node;
find('[data-list-total]').textContent = queue.length ? String(queue.length) : '';
find('[data-history]').textContent = history.minutes == null ? history.error ? '读取失败' : '—' : `${Number(history.minutes.toFixed(1))} 分钟`;
find('[data-history-note]').textContent = historyBusy ? '正在读取历史统计' : history.error ? '历史统计读取失败,可刷新重试' : '历史统计 · 次日更新';
find('.history').dataset.error = String(history.error);
find('[data-cache]').textContent = cacheAt ? `更新于 ${new Date(cacheAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}` : loading ? '正在检测课程资料…' : '';
find('[data-cache]').title = '课程列表使用本地缓存;点击刷新可重新检测。学习时长为平台历史统计。';
const state = completions.get(current?.node);
find('[data-completion]').textContent = completionLabel(state);
find('[data-completion]').classList.toggle('done', state?.complete === true);
find('[data-refresh]').disabled = completionBusy || loading || historyBusy;
const done = queue.filter(node => completions.get(String(node.id))?.complete === true).length;
const unknown = queue.filter(node => completions.get(String(node.id))?.complete == null).length;
find('[data-summary]').textContent = queue.length ? `已完成 ${done} / ${queue.length}${unknown ? ` · ${unknown} 项待确认` : ''}${completionBusy ? ' · 检测中' : ''}` : '完成状态以平台记录为准';
shell.dataset.running = String(running);
const mode = viewMode();
const states = { jumping: ['跳转中', '跳转中', 'loading'], loading: ['加载中', '加载中', 'loading'],
next: ['即将切换', '即将切换', 'loading'], playing: [document.hidden ? '后台播放' : '播放中', '暂停学习', 'pause'],
reading: ['阅读中', '暂停学习', 'pause'], paused: ['已暂停', '继续学习', 'play'],
stopped: ['已停止', settings.mode === 'single' ? '开始当前项' : '开始连续学习', 'play'], ended: ['本次已结束', '重新开始', 'play'],
authChecking: ['确认登录', '确认登录中', 'loading'], signedOut: ['未登录', '请先登录', 'play'],
authError: ['连接失败', '请刷新重试', 'play'], selectCourse: ['未选择课程', '请选择课程', 'play'],
courseLoading: ['读取课程', '正在读取课程', 'loading'], courseError: ['课程不可用', '请刷新重试', 'play'],
selectResource: ['未选择资料', '请选择学习资料', 'play'] };
const [stateText, actionText, icon] = states[mode];
shell.dataset.mode = mode;
launcher.dataset.mode = mode;
find('[data-state]').textContent = stateText;
startButton.disabled = ['jumping', 'loading', 'next', 'authChecking', 'signedOut', 'authError', 'selectCourse', 'courseLoading', 'courseError', 'selectResource'].includes(mode);
startButton.dataset.action = ['playing', 'reading'].includes(mode) ? 'pause' : 'play';
startButton.setAttribute('aria-label', actionText);
find('[data-action-label]').textContent = actionText;
setIcon(find('[data-action-icon]'), icon);
for (const currentIcon of list.querySelectorAll('[data-current-icon]')) setIcon(currentIcon, ['playing', 'reading'].includes(mode) ? 'pause' : 'play');
launcher.title = `${stateText};点击${collapsed ? '展开' : '收起'};拖动移动`;
if (navigation || contextMessage()) report('');
updateNotice();
stopButton.disabled = !running && !userPaused && !navigation;
find('[data-refresh]').disabled = auth.state === 'checking' || authBusy || completionBusy || loading || historyBusy || courseInfoBusy;
const configDisabled = !confirmed || courseInfo.state !== 'ready' || ['jumping', 'next'].includes(mode);
for (const button of ui.querySelectorAll('[data-learning-mode]')) {
button.disabled = configDisabled;
button.setAttribute('aria-pressed', String(button.dataset.learningMode === settings.mode));
}
find('[data-default-minutes]').disabled = configDisabled;
find('[data-resource-minutes]').disabled = configDisabled;
find('[data-reset-resource]').disabled = configDisabled;
const continuous = settings.mode === 'continuous';
const hasNonVideo = queue.some(item => !isVideo(item));
const hasVideo = queue.some(isVideo);
find('[data-continuous-settings]').hidden = !continuous;
find('[data-configuration]').hidden = continuous ? !hasNonVideo : !timed;
find('[data-configuration]').dataset.single = String(!continuous);
find('[data-default-settings]').hidden = !continuous;
find('[data-item-settings]').hidden = !timed;
find('[data-item-name]').textContent = timed ? `当前任务:${node.name || resourceKind(node)}` : '';
find('[data-item-name]').title = find('[data-item-name]').textContent;
find('[data-reset-resource]').textContent = `使用课程默认(${settings.documentMinutes} 分钟)`;
find('[data-duration-intro]').textContent = continuous ? '用于本课程的 PPT、PDF 等自主观看任务。视频按自身时长播放。' : '仅设置当前非视频任务,课程默认时间不会被修改。';
muteInput.closest('.option').hidden = continuous ? !hasVideo : !node || timed;
skipInput.disabled = configDisabled || settings.mode === 'single';
find('[data-config-note]').textContent = timed ? `本项${settings.resourceMinutes[String(node.id)] != null ? '单独设置' : '跟随默认'}:${targetDocumentMs(node) / 60000} 分钟。调整时保留已停留时间。`
: '默认时间应用于本课程的非视频任务,打开任务后可单独调整。';
find('[data-settings-context]').textContent = continuous ? '当前为连续学习,以下设置用于本课程的学习列表。'
: !node ? '先在“全部”中选择一项,再显示对应的任务设置。' : `当前为单项学习,仅显示${timed ? '此非视频任务' : '此视频'}适用的选项。`;
find('[data-settings-save-note]').textContent = continuous ? '课程学习选项按当前账号保存,播放偏好通用。'
: timed ? '本项停留按任务保存,播放偏好通用。' : '播放偏好在各课程间通用。';
find('[data-mode-note]').textContent = !node ? continuous ? '按课程顺序连续学习,可在“设置”中调整规则。' : '先在“全部”中选择一项,结束后停止。'
: timed ? `本项停留 ${targetDocumentMs(node) / 60000} 分钟后${continuous ? '自动学习下一项。' : '停止本次学习。'}`
: continuous ? '当前视频结束后,自动学习下一项。' : '只学当前视频,播放结束后停止。';
find('[data-locate]').disabled = !node;
const canNavigate = confirmed && courseInfo.state === 'ready' && !navigation;
const next = index >= 0 ? queue[index + 1] : queue[0];
find('[data-neighbors]').hidden = !confirmed || !queue.length;
for (const step of ui.querySelectorAll('[data-step]')) {
const previous = step.dataset.step === 'previous';
const target = previous ? index > 0 ? queue[index - 1] : null : next;
const label = previous ? '上一项' : index < 0 ? '第一项' : '下一项';
const name = target ? target.name || String(target.id) : previous ? index < 0 ? '请先选择任务' : '已是第一项' : '已是最后一项';
const enabled = canNavigate && !!target;
step.disabled = !enabled;
step.setAttribute('aria-disabled', String(!enabled));
step.tabIndex = enabled ? 0 : -1;
step.querySelector('[data-step-label]').textContent = label;
step.querySelector('[data-step-name]').textContent = name;
step.title = `${label}:${name}`;
step.setAttribute('aria-label', step.title);
if (enabled) step.dataset.targetNode = String(target.id);
else delete step.dataset.targetNode;
}
}
function setTab(name, save = true) {
if (!['learning', 'list', 'settings'].includes(name)) return;
activeTab = name;
for (const tab of ui.querySelectorAll('[data-tab]')) {
const selected = tab.dataset.tab === name;
tab.setAttribute('aria-selected', String(selected));
tab.tabIndex = selected ? 0 : -1;
}
for (const view of ui.querySelectorAll('[data-view]')) view.hidden = view.dataset.view !== name;
const confirmed = auth.state === 'ready' && !!route();
find('[data-tabs]').hidden = !confirmed;
find('[data-footer]').hidden = !confirmed;
if (save) persist();
place();
}
function syncSettingsInputs() {
find('[data-default-minutes]').value = String(settings.documentMinutes);
const node = currentNode();
find('[data-resource-minutes]').value = node && settings.resourceMinutes[String(node.id)] != null ? String(settings.resourceMinutes[String(node.id)]) : '';
skipInput.checked = settings.skipCompleted;
}
function clampPosition(x, y) {
return { x: Math.max(12, Math.min(x, Math.max(12, innerWidth - 60))),
y: Math.max(12, Math.min(y, Math.max(12, innerHeight - 60))) };
}
function place() {
if (!position || !Number.isFinite(position.x) || !Number.isFinite(position.y)) {
position = { x: innerWidth - 72, y: innerHeight * 0.12 };
}
position = clampPosition(position.x, position.y);
Object.assign(panel.style, { left: `${position.x}px`, top: `${position.y}px`, right: 'auto', bottom: 'auto' });
const bounds = shell.getBoundingClientRect();
const width = bounds.width || Math.min(352, Math.max(0, innerWidth - 24));
const height = bounds.height || 480;
const preferredX = position.x < innerWidth / 2 ? position.x + 60 : position.x - width - 12;
const x = Math.max(12, Math.min(preferredX, innerWidth - width - 12));
const y = Math.max(12, Math.min(position.y, innerHeight - height - 12));
shell.style.left = `${x - position.x}px`;
shell.style.top = `${y - position.y}px`;
}
function setCollapsed() {
shell.hidden = collapsed;
collapseButton.innerHTML = icons.collapse;
collapseButton.setAttribute('aria-expanded', String(!collapsed));
collapseButton.setAttribute('aria-label', collapsed ? '展开面板' : '收起面板');
launcher.setAttribute('aria-expanded', String(!collapsed));
launcher.setAttribute('aria-label', collapsed ? '展开学习助手' : '收起学习助手');
place();
}
let drag = null, suppressLauncherClick = false;
const dragHandle = find('[data-drag]');
for (const handle of [launcher, dragHandle]) {
handle.addEventListener('pointerdown', event => {
if (event.button !== 0 || handle !== launcher && event.target.closest('button')) return;
drag = { startX: event.clientX, startY: event.clientY, x: position.x, y: position.y, moved: false };
suppressLauncherClick = false;
handle.setPointerCapture?.(event.pointerId);
});
handle.addEventListener('pointermove', event => {
if (!drag) return;
const dx = event.clientX - drag.startX, dy = event.clientY - drag.startY;
if (Math.hypot(dx, dy) <= 5 && !drag.moved) return;
drag.moved = true;
position = clampPosition(drag.x + dx, drag.y + dy);
place();
});
handle.addEventListener('pointerup', () => {
if (!drag) return;
suppressLauncherClick = handle === launcher && drag.moved;
drag = null;
persist();
});
handle.addEventListener('pointercancel', () => { drag = null; suppressLauncherClick = false; });
}
window.addEventListener('resize', place);
if (typeof ResizeObserver === 'function') {
const panelSizeObserver = new ResizeObserver(() => { if (!collapsed) place(); });
panelSizeObserver.observe(shell);
}
setCollapsed();
function route() {
const course = location.pathname.match(/^\/app\/jx-web\/mycourse\/(\d+)(?:\/|$)/);
if (!course) return null;
const resource = location.pathname.match(/\/resource\/([^/]+)\/(\d+)\/?$/);
return { course: course[1], folder: resource?.[1] || null, node: resource?.[2] || null };
}
function currentNode() {
const current = route();
return current?.course === queueCourse ? queue.find(node => String(node.id) === current.node) : null;
}
function updateDocumentClock(active) {
if (!documentClock) return;
const now = Date.now();
if (active && documentClock.lastAt != null) {
documentClock.elapsedMs += Math.max(0, now - documentClock.lastAt);
}
documentClock.lastAt = active ? now : null;
}
function processDocument() {
const current = route(), node = currentNode();
if (!node || isVideo(node)) return;
if (!documentClock || documentClock.node !== current.node || documentClock.course !== current.course) {
documentClock = { course: current.course, node: current.node, elapsedMs: 0, lastAt: null, ready: false };
}
const body = document.querySelector('.group-resource-body .group-resource-content');
const failure = body && [...body.querySelectorAll('.ant-result-error, .resource-error, [data-preview-error="true"]')].find(visible);
if (failure) {
updateDocumentClock(false);
resourceFailure = '资料预览失败,请在平台页面处理后重新开始。';
stop(resourceFailure);
return;
}
documentClock.ready = !!body && !body.querySelector('[aria-busy="true"], .ant-spin-spinning');
const active = running && documentClock.ready && !navigation && !hasOpenDialog() && (background || !document.hidden);
updateDocumentClock(active);
if (active) {
report(`正在阅读${resourceKind(node)},停留 ${targetDocumentMs(node) / 60000} 分钟后${settings.mode === 'single' ? '结束本次学习' : '切换下一项'}。`);
if (documentClock.elapsedMs >= targetDocumentMs(node)) scheduleResourceNext(current.node, null, 0);
}
if (navigation && documentClock.ready) finishNavigation();
updateView();
}
function visible(element) {
return element && element.isConnected && element.getClientRects().length > 0 &&
getComputedStyle(element).visibility !== 'hidden';
}
function detachVideo() {
if (video) {
video.removeEventListener('ended', endedListener);
for (const event of ['loadedmetadata', 'loadeddata', 'canplay', 'seeked']) video.removeEventListener(event, readyListener);
video.removeEventListener('pause', pauseListener);
video.removeEventListener('play', playListener);
}
video = null;
playPending = false;
waitingForVideoReady = false;
backgroundIntent = false;
clearTimeout(backgroundTimer);
backgroundTimer = null;
}
function stop(message = '已停止', paused = false, ended = false) {
updateDocumentClock(running);
if (documentClock) documentClock.lastAt = null;
running = false;
userPaused = paused;
sessionEnded = ended;
navigation = null;
outgoingVideo = null;
generation++;
loading = false;
clearTimeout(nextTimer);
nextTimer = null;
advancing = false;
clearTimeout(backgroundTimer);
backgroundTimer = null;
backgroundIntent = false;
resumeAfterDialog = false;
if (video && !video.paused) video.pause();
persist();
report(message);
updateView();
}
function beginNavigation(node, course) {
updateDocumentClock(running);
if (documentClock) documentClock.lastAt = null;
sessionEnded = false;
resourceFailure = '';
navigation = { course, node: String(node.id), name: node.name || '', fromNode: route()?.node, startedAt: Date.now() };
outgoingVideo = video;
outgoingSource = video?.currentSrc || '';
generation++;
loading = false;
clearTimeout(nextTimer);
nextTimer = null;
advancing = false;
backgroundIntent = false;
if (video && !video.paused) video.pause();
detachVideo();
setTab('learning', false);
persist();
report('');
updateView();
}
function finishNavigation() {
if (!navigation) return;
navigation = null;
outgoingVideo = null;
outgoingSource = '';
persist();
report(running ? '正在准备学习…' : '已打开资料,点击主按钮即可开始学习。');
}
function openResourceFromClick(event, node, course) {
event.preventDefault();
event.stopImmediatePropagation();
if (!node || event.button !== 0) return;
if (route()?.course === course && route()?.node === String(node.id)) return;
// Task controls are buttons: no native link default or modifier-key new-tab behavior.
// Persist playback intent before explicitly navigating this browsing context.
const destination = resourcePath(course, node);
beginNavigation(node, course);
location.assign(destination);
}
function filteredQueue() {
const term = queueSearch.normalize('NFKC').trim().toLocaleLowerCase();
return queue.filter(node => {
const done = completions.get(String(node.id))?.complete === true;
return (queueFilter === 'all' || (queueFilter === 'completed' ? done : !done)) &&
(!term || String(node.name || node.id).normalize('NFKC').toLocaleLowerCase().includes(term));
});
}
function showQueue() {
const previousScroll = list.scrollTop;
list.replaceChildren();
const current = route()?.node;
const nodes = filteredQueue();
if (!nodes.length) {
const empty = document.createElement('li');
empty.className = 'queue-empty';
empty.textContent = queue.length ? queueSearch ? '没有找到匹配的资料' : '当前筛选下没有资料' :
auth.state !== 'ready' ? '登录后显示学习资料' : !route() ? '选择课程后自动检测学习资料'
: loading ? '正在读取课程…' : directoryIssue || '当前课程暂未检测到视频或自主观看资料';
list.appendChild(empty);
}
for (const node of nodes) {
const number = queue.indexOf(node);
const item = document.createElement('li');
const link = document.createElement('button');
link.type = 'button';
link.className = 'queue-link';
link.dataset.node = String(node.id);
const currentItem = String(node.id) === current;
item.classList.toggle('current', currentItem);
if (currentItem) link.setAttribute('aria-current', 'page');
const numberLabel = document.createElement('span');
numberLabel.className = 'num';
if (currentItem) { numberLabel.dataset.currentIcon = ''; setIcon(numberLabel, ['playing', 'reading'].includes(viewMode()) ? 'pause' : 'play'); }
else numberLabel.textContent = String(number + 1).padStart(2, '0');
const title = document.createElement('span');
title.className = 'name';
title.textContent = node.name || node.id;
title.title = title.textContent;
const badge = document.createElement('span');
badge.className = 'completion';
badge.dataset.completionFor = String(node.id);
const kind = document.createElement('span');
kind.className = 'kind';
kind.textContent = resourceKind(node);
link.append(numberLabel, kind, title, badge);
link.onclick = event => openResourceFromClick(event, node, queueCourse);
item.appendChild(link);
list.appendChild(item);
}
list.scrollTop = previousScroll;
updateCompletionBadges(false);
place();
}
function updateCompletionBadges(reconcile = true) {
if (reconcile) {
const expected = filteredQueue().map(node => String(node.id)).join(',');
const rendered = [...list.querySelectorAll('[data-node]')].map(link => link.dataset.node).join(',');
if (expected !== rendered) { showQueue(); return; }
}
for (const badge of list.querySelectorAll('[data-completion-for]')) {
const state = completions.get(badge.dataset.completionFor);
badge.textContent = completionLabel(state);
badge.classList.toggle('done', state?.complete === true);
badge.title = state?.error ? '状态读取失败,点击“刷新状态”重试' : state?.hasTask
? '任务完成状态以平台返回的 finished 字段为准'
: '根据平台已记录的观看时长计算';
}
updateView();
}
function resetCourseContext(course) {
updateDocumentClock(running);
documentClock = null;
if (video && !video.paused) video.pause();
detachVideo();
generation++;
courseEpoch++;
completionEpoch++;
observedCourse = course;
queue = [];
queueSearch = '';
queueFilter = 'all';
find('[data-search]').value = '';
for (const filter of ui.querySelectorAll('[data-filter]')) filter.setAttribute('aria-pressed', String(filter.dataset.filter === 'all'));
list.scrollTop = 0;
queueCourse = course || '';
routeKey = '';
cacheAt = 0;
loading = false;
completionBusy = false;
historyBusy = false;
courseInfoBusy = false;
history = { minutes: null, fetchedAt: 0, error: false };
courseInfo = { name: '', state: course ? 'loading' : 'none', fetchedAt: 0 };
directoryCheckedAt = 0;
currentCheckAt = 0;
directoryIssue = '';
resourceFailure = '';
sessionEnded = false;
userPaused = false;
completions.clear();
if (course && auth.state === 'ready') loadSettings(course);
else { settings = normalizeSettings(); skipCompleted = false; syncSettingsInputs(); }
showQueue();
}
function invalidateAuthentication(message = '登录已过期,请先登录') {
authEpoch++;
auth = { state: 'signedOut', id: null, message, checkedAt: Date.now() };
authBusy = false;
if (!loginNoticeShown) {
loginNoticeShown = true;
collapsed = false;
setCollapsed();
}
stop(message);
resetCourseContext(null);
updateView();
}
async function verifyAuthentication(force = false) {
if (loginSurface) { invalidateAuthentication('请先登录'); return; }
if (authBusy) return;
const scope = tokenScope();
if (!scope) { invalidateAuthentication('请先登录'); cacheScope = null; return; }
if (!force && auth.state === 'ready' && scope === cacheScope && Date.now() - auth.checkedAt < 5 * 60000) return;
const epoch = ++authEpoch;
authBusy = true;
if (auth.state !== 'ready') auth = { state: 'checking', id: null, message: '', checkedAt: Date.now() };
updateView();
try {
const account = await readApi('/api/jx-auth/oauth2/info');
if (epoch !== authEpoch || scope !== tokenScope()) return;
if (account?.info?.id == null) throw Object.assign(new Error('请先登录'), { status: 401 });
cacheScope = scope;
auth = { state: 'ready', id: String(account.info.id), message: '', checkedAt: Date.now() };
if (saved.accountId && String(saved.accountId) !== auth.id) stop('账号已切换,请选择课程后重新开始。');
} catch (error) {
if (epoch !== authEpoch) return;
if (error.status === 401) invalidateAuthentication('登录已过期,请先登录');
else {
auth = { state: 'error', id: null, message: error.message, checkedAt: Date.now() };
stop('无法确认登录状态,请点击刷新重试。');
resetCourseContext(null);
}
} finally {
if (epoch === authEpoch) authBusy = false;
if (auth.state === 'ready') tick();
else updateView();
}
}
async function loadCourseInfo(course, force = false) {
if (courseInfoBusy) return;
const epoch = courseEpoch;
const cached = readCache(course)?.courseInfo;
if (!force && cached?.name) {
courseInfo = { ...cached, state: 'ready' };
if (Date.now() - cached.fetchedAt < QUEUE_TTL) { updateView(); return; }
}
courseInfoBusy = true;
try {
const data = await readApi(`/api/jx-iresource/group/queryGroup/${encodeURIComponent(course)}`);
if (epoch !== courseEpoch) return;
const name = data?.name || data?.group?.name || data?.site_name;
if (typeof name !== 'string' || !name.trim() || data?.id != null && String(data.id) !== course) throw new Error('课程信息格式异常');
courseInfo = { name: name.trim(), state: 'ready', fetchedAt: Date.now() };
writeCache();
} catch (error) {
if (epoch !== courseEpoch) return;
if (error.status === 403) courseInfo = { name: '', state: 'forbidden', fetchedAt: 0 };
else if (!courseInfo.name) courseInfo = { name: '', state: 'error', fetchedAt: 0 };
if (running) stop('课程信息读取失败,本次学习已停止。');
} finally {
if (epoch === courseEpoch) { courseInfoBusy = false; updateView(); place(); }
}
}
async function readApi(path) {
const entry = tokenEntry();
if (!entry) throw new Error('未找到登录状态,请先登录平台');
const token = decodeURIComponent(entry.slice(entry.indexOf('=') + 1));
const response = await fetch(path, {
credentials: 'same-origin', headers: { authorization: `Bearer ${token}` }
});
if (!response.ok) {
if (response.status === 401 && !path.includes('/oauth2/info')) invalidateAuthentication();
throw Object.assign(new Error(`请求失败:HTTP ${response.status}`), { status: response.status });
}
const result = await response.json();
if (!result.success) throw new Error(result.message || '平台未返回有效数据');
return result.data;
}
function checkCompletion(node, force = false) {
const id = String(node.id), course = queueCourse, epoch = completionEpoch;
const key = `${epoch}/${id}`;
if (pendingChecks.has(key)) return pendingChecks.get(key);
const previous = completions.get(id);
if (!force && previous?.checkedAt && Date.now() - previous.checkedAt < 30000) return Promise.resolve(previous);
const pending = (async () => {
await Promise.resolve();
try {
const task = node.task_id || node.task?.task_id;
let state;
if (task) {
const params = new URLSearchParams({ group_id: course, node_id: id, task_id: String(task) });
const data = await readApi(`/api/jx-iresource/resource/task/studentFinishInfo/v2?${params}`);
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('任务状态格式异常');
state = completionState(node, data);
} else if (node.quote_id && isVideo(node)) {
const data = await readApi(`/api/jx-iresource/vod/duration/${encodeURIComponent(String(node.quote_id))}`);
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('观看记录格式异常');
state = completionState(node, Object.keys(data).length ? data : { duration: 1, watched_duration: 0 });
} else {
state = completionState(node, node.watchRecord || node);
}
state.checkedAt = Date.now();
if (epoch === completionEpoch && course === queueCourse) completions.set(id, state);
return state;
} catch {
const state = { complete: null, percent: previous?.percent ?? null,
hasTask: !!(node.task_id || node.task?.task_id), error: true, checkedAt: Date.now() };
if (epoch === completionEpoch && course === queueCourse) completions.set(id, state);
return state;
} finally {
pendingChecks.delete(key);
if (epoch === completionEpoch && course === queueCourse) { updateCompletionBadges(); writeCache(); }
}
})();
pendingChecks.set(key, pending);
return pending;
}
async function refreshCompletion(force = false) {
if (completionBusy || !queue.length) return;
completionBusy = true;
const epoch = completionEpoch, nodes = [...queue];
let cursor = 0;
updateView();
try {
await Promise.all(Array.from({ length: Math.min(3, nodes.length) }, async () => {
while (cursor < nodes.length && epoch === completionEpoch) await checkCompletion(nodes[cursor++], force);
}));
} finally {
if (epoch === completionEpoch) { completionBusy = false; updateCompletionBadges(); }
}
}
async function loadHistory(course, force = false) {
if (historyBusy) return;
const epoch = courseEpoch;
const cached = readCache(course)?.history;
if (!force && cached && Number.isFinite(cached.minutes)) {
history = cached;
if (Date.now() - cached.fetchedAt < HISTORY_TTL) { updateView(); return; }
}
historyBusy = true;
updateView();
try {
const userId = auth.id;
if (userId == null) throw new Error('无法读取当前账号');
const params = new URLSearchParams({ group_id: course, user_id: String(userId), client_type: '3', unit: '1' });
const data = await readApi(`/api/jx-stat/ads/duration/user?${params}`);
if (data?.total_duration == null || !Number.isFinite(Number(data.total_duration)) || Number(data.total_duration) < 0) {
throw new Error('历史统计格式异常');
}
if (epoch !== courseEpoch) return;
history = { minutes: Number(data.total_duration), fetchedAt: Date.now(), error: false };
writeCache();
} catch {
if (epoch === courseEpoch) history = { ...history, error: true };
} finally {
if (epoch === courseEpoch) { historyBusy = false; updateView(); }
}
}
function applyQueue(nodes, course, cachedStates = []) {
queue = nodes;
queueCourse = course;
completionEpoch++;
completionBusy = false;
completions.clear();
const states = new Map(cachedStates);
for (const node of queue) {
const id = String(node.id), cached = states.get(id);
completions.set(id, cached && Date.now() - cached.checkedAt < STATUS_TTL ? cached : completionState(node,
node.task_id || node.task?.task_id ? node : node.watchRecord || node));
}
syncSettingsInputs();
showQueue();
refreshCompletion();
}
async function loadQueue(current, force = false) {
if (loading) return;
const version = courseEpoch;
const cached = readCache(current.course);
if (!force && cached) {
cacheAt = cached.cachedAt;
applyQueue(cached.queue.filter(isStudyResource), current.course, cached.completions || []);
if (Date.now() - cached.cachedAt < QUEUE_TTL) {
directoryCheckedAt = Date.now();
return;
}
}
loading = true;
directoryIssue = '';
directoryCheckedAt = Date.now();
report('正在检测本课程的学习资料…');
updateView();
try {
const resources = await readApi(`/api/jx-iresource/resource/queryCourseResources/v2?group_id=${current.course}`);
if (!Array.isArray(resources)) throw new Error('目录接口格式不符合预期');
if (version !== courseEpoch || route()?.course !== current.course) return;
cacheAt = Date.now();
applyQueue(buildQueue(resources), current.course);
writeCache();
report(running ? `已检测 ${queue.length} 项,正在准备学习…` : '在“全部”中选择任务,或点击主按钮从当前项开始学习。');
} catch (error) {
if (version === courseEpoch) {
directoryIssue = error.status === 403 ? '当前账号无权读取课程资料。' : `列表读取失败:${error.message},点击刷新重试。`;
report(directoryIssue);
}
} finally {
if (version === courseEpoch) { loading = false; updateView(); place(); }
}
}
async function playCurrent() {
const target = video;
if (!running || !target || target.ended || target.error || playPending) return;
// A metadata-only preload may need play() to request buffered frames.
if (target.readyState < 1) { waitingForVideoReady = true; return; }
waitingForVideoReady = false;
const version = generation;
playPending = true;
updateView();
target.muted = muted;
try {
await target.play();
if (!running || video !== target || version !== generation) {
if (!running || video !== target) target.pause();
return;
}
finishNavigation();
report(document.hidden ? '已在后台继续播放,可切换到其他标签页。'
: settings.mode === 'single' ? '当前视频结束后,将结束本次学习。' : '视频结束后,将按课程目录自动播放下一节。');
} catch (error) {
if (running && video === target && version === generation) {
finishNavigation();
report(error.name === 'NotAllowedError' ? '浏览器限制了自动播放,请手动点击一次视频播放按钮'
: `播放失败:${error.message},可手动播放后继续`);
}
} finally {
if (video === target && version === generation) playPending = false;
updateView();
}
}
function hasOpenDialog() {
return [...document.querySelectorAll('.ant-modal, [role="dialog"]')].some(visible);
}
function recoverBackground() {
if (!running || !background || !backgroundIntent || !video?.paused || video.ended ||
video.error || video.seeking || video.readyState < 2 || resumeAfterDialog || hasOpenDialog() ||
backgroundAttempts >= 3 || Date.now() - lastBackgroundAttempt < 3000) return;
backgroundAttempts++;
lastBackgroundAttempt = Date.now();
playCurrent();
}
function queueBackgroundRecovery() {
if (backgroundTimer || !running || !background || !backgroundIntent) return;
backgroundTimer = setTimeout(() => { backgroundTimer = null; recoverBackground(); }, 250);
}
// 在平台的普通 visibilitychange 监听器之前保留播放意图;不修改页面可见性。
document.addEventListener('visibilitychange', () => {
if (documentClock && !background) updateDocumentClock(false);
clearTimeout(backgroundTimer);
backgroundTimer = null;
if (!running || !background || !video) return;
if (document.hidden) {
backgroundIntent = !video.paused && !video.ended;
backgroundAttempts = 0;
lastBackgroundAttempt = 0;
}
if (backgroundIntent) queueBackgroundRecovery();
updateView();
}, true);
function scheduleNext(currentVideo) {
const current = route();
if (!current || video !== currentVideo || !currentVideo.ended) return;
scheduleResourceNext(current.node, currentVideo, 5000);
}
function scheduleResourceNext(nodeId, currentVideo, delay) {
const current = route();
if (!running || !current || current.node !== nodeId || nextTimer || advancing) return;
const index = queue.findIndex(node => String(node.id) === current.node);
if (index < 0) { stop('无法在目录中定位当前资料,已停止'); return; }
const next = nextVideo(queue, current.node, completions, skipCompleted);
const finished = () => currentVideo ? video === currentVideo && currentVideo.ended
: documentClock?.node === nodeId && documentClock.elapsedMs >= targetDocumentMs(queue[index]);
report(settings.mode === 'single' ? '当前项已结束,正在确认平台记录。'
: next ? delay ? `视频结束,5 秒后切换:${next.name}` : `计划停留已结束,正在切换:${next.name}` : '当前项已结束,正在确认平台记录。');
const version = generation;
nextTimer = setTimeout(async () => {
nextTimer = null;
if (!running || generation !== version || route()?.node !== current.node || !finished()) return;
advancing = true;
try {
await checkCompletion(queue[index], true);
if (!running || generation !== version || route()?.node !== current.node || !finished()) return;
if (settings.mode === 'single') {
const state = completions.get(nodeId);
stop(state?.complete === true ? '本次学习已结束,平台记录已完成。'
: currentVideo ? '本次播放已结束,平台完成状态待确认。' : '计划停留已结束,平台完成状态待确认。', false, true);
return;
}
const target = nextVideo(queue, current.node, completions, skipCompleted);
if (!target) { stop('本次学习已结束,后续没有需要继续学习的资料。', false, true); return; }
beginNavigation(target, current.course);
location.assign(resourcePath(current.course, target));
} finally { if (generation === version) advancing = false; }
}, delay);
updateView();
}
function bindVideo() {
const found = [...document.querySelectorAll('.group-resource-body .ali_player_wrapper video')].find(visible);
if (navigation && found === outgoingVideo && (found?.currentSrc || '') === outgoingSource) return;
if (found === video) {
// A cached/seeked player can become ready without another loadeddata event.
if (running && (navigation || waitingForVideoReady) && !resumeAfterDialog && !hasOpenDialog()) playCurrent();
return;
}
detachVideo();
if (!found) return;
video = found;
endedListener = () => scheduleNext(found);
readyListener = () => {
if (video !== found || found.readyState < 1) return;
if (!running) { finishNavigation(); updateView(); }
else if ((navigation || waitingForVideoReady) && !resumeAfterDialog && !hasOpenDialog()) playCurrent();
};
pauseListener = () => {
if (running && background && backgroundIntent && document.hidden) {
queueBackgroundRecovery();
} else if (!document.hidden) {
backgroundIntent = false;
if (running && !found.ended && !resumeAfterDialog) report('视频已暂停,点击“继续学习”即可继续。');
}
updateView();
};
playListener = () => {
waitingForVideoReady = false;
finishNavigation();
userPaused = false;
if (running && background && document.hidden) backgroundIntent = true;
updateView();
};
found.addEventListener('ended', endedListener);
for (const event of ['loadedmetadata', 'loadeddata', 'canplay', 'seeked']) found.addEventListener(event, readyListener);
found.addEventListener('pause', pauseListener);
found.addEventListener('play', playListener);
if (!running && found.readyState >= 2) finishNavigation();
if (found.ended) scheduleNext(found);
else playCurrent();
}
function handleContinueDialog() {
const dialogs = [...document.querySelectorAll('.ant-modal, [role="dialog"]')].filter(visible);
const dialog = dialogs.find(element => /长时间[\s\S]{0,40}(未操作|没有操作)/.test(element.textContent || ''));
if (!dialog) {
if (resumeAfterDialog) {
resumeAfterDialog = false;
playCurrent();
}
return false;
}
const button = [...dialog.querySelectorAll('button')].find(element =>
element.textContent.replace(/\s/g, '') === '继续学习' && !element.disabled && visible(element));
if (button && Date.now() - lastDialogClick > 3000) {
lastDialogClick = Date.now();
resumeAfterDialog = true;
button.click();
report('已点击“继续学习”,等待弹窗关闭后恢复播放…');
} else if (!button) report('检测到未操作提示,但未找到“继续学习”按钮,请手动处理');
return true;
}
function tick() {
if (loginSurface) {
if (auth.state !== 'signedOut' || running || navigation) invalidateAuthentication('请先登录');
else updateView();
return;
}
const scope = tokenScope();
if (scope !== cacheScope) {
authEpoch++;
authBusy = false;
stop('登录状态已变化,请重新确认。');
resetCourseContext(null);
cacheScope = scope;
auth = { state: 'checking', id: null, message: '', checkedAt: 0 };
verifyAuthentication(true);
return;
}
if (auth.state !== 'ready') {
if (auth.state === 'checking' && !authBusy) verifyAuthentication();
updateView();
return;
}
if (!authBusy && Date.now() - auth.checkedAt >= 5 * 60000) verifyAuthentication();
const current = route();
const course = current?.course || null;
if (course !== observedCourse) {
if (running && (!current || activeCourse !== course)) stop('已切换课程,连播停止。');
resetCourseContext(course);
if (current) { loadCourseInfo(course); loadQueue(current); loadHistory(course); }
}
updateView();
if (navigation) {
if (Date.now() - navigation.startedAt >= 30000) { stop('跳转等待超时,请刷新页面后重试。'); return; }
if (current?.course === navigation.course && current.node === navigation.fromNode && current.node !== navigation.node) return;
if (current?.course !== navigation.course || current.node !== navigation.node) finishNavigation();
else if (loading && !queue.length) return;
}
const key = current ? `${current.course}/${current.node || ''}` : '';
if (key !== routeKey) {
updateDocumentClock(running);
documentClock = null;
routeKey = key;
generation++;
clearTimeout(nextTimer);
nextTimer = null;
advancing = false;
resumeAfterDialog = false;
sessionEnded = false;
userPaused = false;
resourceFailure = '';
if (video && !video.paused) video.pause();
detachVideo();
syncSettingsInputs();
showQueue();
}
if (current && !loading && Date.now() - directoryCheckedAt >= (queue.length ? QUEUE_TTL : 30000)) loadQueue(current);
if (current?.node && queueCourse === current.course && Date.now() - currentCheckAt >= 30000) {
currentCheckAt = Date.now();
const node = queue.find(item => String(item.id) === current.node);
if (node) checkCompletion(node);
}
if (!running) {
if (navigation && currentNode()) {
if (isVideo(currentNode())) bindVideo();
else processDocument();
}
updateView();
return;
}
if (courseInfo.state !== 'ready') return;
if (!current?.node || current.course !== activeCourse) {
stop('已离开当前学习资料页面,连播停止');
return;
}
if (loading && !queue.length) return;
const node = currentNode();
if (!node) { stop('当前项不是视频或自主观看资料,请从列表选择。'); return; }
if (handleContinueDialog()) { updateDocumentClock(false); return; }
if (isVideo(node)) { bindVideo(); recoverBackground(); }
else processDocument();
updateView();
}
startButton.onclick = () => {
if (startButton.disabled || auth.state !== 'ready' || courseInfo.state !== 'ready') return;
if (['playing', 'reading'].includes(viewMode())) { stop('已暂停,点击继续学习。', true); return; }
const current = route();
if (!current) { report('请先进入课程'); return; }
if (!queue.length) { report('暂未检测到可学习资料,可点击刷新重新检测。'); return; }
if (settings.mode === 'single' && !currentNode()) { report('请先从列表选择一项学习资料。'); return; }
if (sessionEnded) {
if (documentClock) { documentClock.elapsedMs = 0; documentClock.lastAt = null; }
if (video) video.currentTime = 0;
}
sessionEnded = false;
resourceFailure = '';
activeCourse = current.course;
running = true;
userPaused = false;
persist();
if (!currentNode()) {
const target = queue.find(node => !skipCompleted || completions.get(String(node.id))?.complete !== true);
if (!target) { stop('本课程列表中的资料均已完成。'); return; }
beginNavigation(target, current.course);
location.assign(resourcePath(current.course, target));
return;
}
tick();
if (video) playCurrent();
};
stopButton.onclick = () => stop();
launcher.onclick = () => {
if (suppressLauncherClick) { suppressLauncherClick = false; return; }
collapsed = !collapsed;
setCollapsed();
persist();
if (!collapsed) {
const current = route();
if (current && queueCourse !== current.course) loadQueue(current);
else showQueue();
}
updateView();
};
collapseButton.onclick = () => { collapsed = !collapsed; setCollapsed(); persist(); };
find('[data-refresh]').onclick = () => {
if (auth.state !== 'ready' || tokenScope() !== cacheScope) { verifyAuthentication(true); return; }
const current = route();
if (!current) { report('请选择课程'); return; }
loadCourseInfo(current.course, true);
loadQueue(current, true);
loadHistory(current.course, true);
};
find('[data-empty-retry]').onclick = () => find('[data-refresh]').click();
for (const tab of ui.querySelectorAll('[data-tab]')) {
tab.onclick = () => setTab(tab.dataset.tab);
tab.onkeydown = event => {
const tabs = [...ui.querySelectorAll('[data-tab]')];
const index = tabs.indexOf(tab);
const next = event.key === 'ArrowRight' ? (index + 1) % tabs.length : event.key === 'ArrowLeft' ? (index + tabs.length - 1) % tabs.length
: event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : -1;
if (next < 0) return;
event.preventDefault();
setTab(tabs[next].dataset.tab);
tabs[next].focus();
};
}
find('[data-search]').oninput = event => { queueSearch = event.target.value; list.scrollTop = 0; showQueue(); };
for (const filter of ui.querySelectorAll('[data-filter]')) {
filter.onclick = () => {
queueFilter = filter.dataset.filter;
for (const item of ui.querySelectorAll('[data-filter]')) item.setAttribute('aria-pressed', String(item === filter));
list.scrollTop = 0;
showQueue();
};
}
find('[data-locate]').onclick = () => {
queueSearch = '';
queueFilter = 'all';
find('[data-search]').value = '';
for (const filter of ui.querySelectorAll('[data-filter]')) filter.setAttribute('aria-pressed', String(filter.dataset.filter === 'all'));
showQueue();
const current = list.querySelector('[aria-current="page"]');
current?.scrollIntoView?.({ block: 'nearest', behavior: typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
current?.focus({ preventScroll: true });
};
for (const link of ui.querySelectorAll('[data-step]')) link.onclick = event => {
const id = event.currentTarget.dataset.targetNode;
const node = queue.find(item => String(item.id) === id);
openResourceFromClick(event, node, queueCourse);
};
ui.addEventListener('keydown', event => {
if (event.key !== 'Escape') return;
event.preventDefault();
collapsed = true;
setCollapsed();
persist();
launcher.focus();
});
function changeSettings(change) {
if (auth.state !== 'ready' || !route() || courseInfo.state !== 'ready') return;
updateDocumentClock(running && !hasOpenDialog());
if (nextTimer || advancing) {
generation++;
clearTimeout(nextTimer);
nextTimer = null;
advancing = false;
}
change();
skipCompleted = settings.skipCompleted;
saveSettings();
syncSettingsInputs();
find('[data-config-error]').textContent = '';
if (running) {
const node = currentNode();
if (node && !isVideo(node)) processDocument();
else if (video?.ended) scheduleNext(video);
} else report('学习设置已保存到当前课程。');
updateView();
place();
}
for (const button of ui.querySelectorAll('[data-learning-mode]')) {
button.onclick = () => changeSettings(() => { settings.mode = button.dataset.learningMode; });
}
find('[data-default-minutes]').onchange = event => {
if (!validMinutes(event.target.value)) { find('[data-config-error]').textContent = '请输入 2 至 1440 的整数分钟数。'; return; }
changeSettings(() => { settings.documentMinutes = Number(event.target.value); });
};
find('[data-resource-minutes]').onchange = event => {
const node = currentNode();
if (!node || isVideo(node)) return;
const value = event.target.value;
if (value !== '' && !validMinutes(value)) { find('[data-config-error]').textContent = '请输入 2 至 1440 的整数分钟数,留空使用课程默认。'; return; }
changeSettings(() => {
if (value === '') delete settings.resourceMinutes[String(node.id)];
else settings.resourceMinutes[String(node.id)] = Number(value);
});
};
find('[data-reset-resource]').onclick = () => {
const node = currentNode();
if (node) changeSettings(() => { delete settings.resourceMinutes[String(node.id)]; });
};
skipInput.onchange = () => changeSettings(() => { settings.skipCompleted = skipInput.checked; });
muteInput.onchange = () => { muted = muteInput.checked; persist(); if (video) video.muted = muted; };
backgroundInput.onchange = () => {
background = backgroundInput.checked;
backgroundIntent = background && running && !!video && !video.paused && document.hidden;
backgroundAttempts = 0;
lastBackgroundAttempt = 0;
if (!background) { clearTimeout(backgroundTimer); backgroundTimer = null; }
persist();
};
report(running ? '正在加载学习资料…' : '进入课程后自动检测,点击主按钮开始学习。');
window.addEventListener('pagehide', () => {
updateDocumentClock(running);
if (documentClock) documentClock.lastAt = null;
clearTimeout(nextTimer);
clearTimeout(backgroundTimer);
detachVideo();
});
window.addEventListener('pageshow', tick);
setInterval(tick, 1000);
setTab(activeTab, false);
showQueue();
tick();
})();