// ==UserScript==
// @name 重庆高教在线课程播放器
// @namespace https://www.cqooc.com/
// @version 3.0.0
// @description 课程播放控制台:视频播放/暂停、倍速、进度、自动连播、课件队列、本地计时与运行日志。不自动答题或提交测验。
// @match *://*.cqooc.com/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-idle
// @license MIT
// ==/UserScript==
(() => {
'use strict';
const VERSION = '3.0.0';
const PREFIX = 'cqooc-player-v3:';
const PANEL_ID = 'cqooc-player-v3-panel';
const DOCK_ID = 'cqooc-player-v3-dock';
const COURSE_PATHS = ['/course/detail/courseStudy', '/learn/mooc/structure'];
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3, 3.5, 4, 5, 6, 8, 10, 12, 16];
const defaults = {
speed: 1,
autoContinue: false,
autoPlayOnSwitch: true,
skipAssessments: true,
skipNonVideo: true,
pauseRecovery: false,
rememberSpeed: true
};
const state = {
settings: { ...defaults },
rows: [],
activeRow: null,
lastActiveRow: null,
video: null,
logs: [],
tab: 'player',
collapsed: false,
hidden: false,
listOpen: true,
advancing: false,
advanceToken: 0,
playAttemptToken: 0,
elapsedMs: 0,
timerRunning: false,
lastTick: 0,
timerId: null,
observer: null,
refreshQueued: false,
courseKey: '',
record: null,
seeking: false,
lastPath: location.pathname,
pauseRecoveryTimer: null,
activeMediaSource: ''
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const isCoursePage = () => COURSE_PATHS.some(path => location.pathname.includes(path));
const gmGet = (key, fallback) => {
try { return typeof GM_getValue === 'function' ? GM_getValue(key, fallback) : fallback; }
catch (_) { return fallback; }
};
const gmSet = (key, value) => {
try { if (typeof GM_setValue === 'function') GM_setValue(key, value); }
catch (_) {}
};
function getCourseKey() {
const params = new URL(location.href).searchParams;
return `${PREFIX}record:${params.get('id') || params.get('courseId') || location.pathname}`;
}
function loadSettings() {
const saved = gmGet(`${PREFIX}settings`, {});
state.settings = { ...defaults, ...(saved && typeof saved === 'object' ? saved : {}) };
const savedSpeed = Number(state.settings.speed);
state.settings.speed = SPEEDS.includes(savedSpeed) ? savedSpeed : 1;
}
function saveSettings() {
gmSet(`${PREFIX}settings`, state.settings);
}
function loadRecord() {
state.courseKey = getCourseKey();
const saved = gmGet(state.courseKey, null);
state.record = saved && typeof saved === 'object'
? saved
: { elapsedMs: 0, completed: {}, lastItem: '', updatedAt: 0 };
state.record.completed ||= {};
state.elapsedMs = Number(state.record.elapsedMs) || 0;
}
function saveRecord() {
if (!state.record) return;
state.record.elapsedMs = state.elapsedMs;
state.record.updatedAt = Date.now();
gmSet(state.courseKey, state.record);
}
function formatTime(seconds) {
const total = Math.floor(Math.max(0, Number(seconds) || 0));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return h > 0
? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
: `${m}:${String(s).padStart(2, '0')}`;
}
function log(level, message) {
const item = { level, message: String(message), time: new Date().toLocaleTimeString() };
state.logs.push(item);
if (state.logs.length > 180) state.logs.shift();
const colors = { info: '#607f89', success: '#54815e', warn: '#b27b36', error: '#b85e58' };
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log';
console[method](`%c[课程播放器 ${item.time}] ${item.message}`, `color:${colors[level] || '#777'};font-weight:600`);
renderLogs();
}
function addStyle(css) {
if (typeof GM_addStyle === 'function') GM_addStyle(css);
else {
const style = document.createElement('style');
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
}
}
function installStyles() {
addStyle(`
#${PANEL_ID}, #${PANEL_ID} * , #${DOCK_ID} { box-sizing:border-box; }
#${PANEL_ID} {
position:fixed; top:68px; right:22px; width:min(790px,calc(100vw - 28px));
max-height:calc(100vh - 90px); overflow:hidden; z-index:2147483000;
color:#4d4a43; background:#f4eddf;
border:1px solid #c9bba4; border-radius:8px 12px 9px 11px;
box-shadow:0 16px 42px rgba(44,39,31,.24);
font-family:Georgia,"Noto Serif SC","Songti SC",serif;
}
#${PANEL_ID} button, #${PANEL_ID} select, #${PANEL_ID} input { font:inherit; }
#${PANEL_ID} .cp-head {
min-height:42px; padding:10px 17px; display:flex; align-items:center; justify-content:space-between;
background:#e9dfce; border-bottom:1px solid #d5c7b1; cursor:move; user-select:none;
}
#${PANEL_ID} .cp-brand { color:#7b7163; font:11px Georgia,serif; letter-spacing:2px; }
#${PANEL_ID} .cp-head-actions { display:flex; gap:7px; }
#${PANEL_ID} .cp-icon-btn { width:27px;height:25px;border:0;background:transparent;color:#635b50;font-size:19px;cursor:pointer; }
#${PANEL_ID} .cp-tabs { display:flex; gap:4px; padding:8px 15px 0; background:#f4eddf; border-bottom:1px solid #d9cdbb; }
#${PANEL_ID} .cp-tab { padding:9px 15px 10px; border:0; border-bottom:2px solid transparent; background:transparent; color:#8a8174; font-size:13px; cursor:pointer; }
#${PANEL_ID} .cp-tab.active { color:#9b5f58; border-bottom-color:#b96f67; font-weight:700; }
#${PANEL_ID} .cp-body { padding:17px 20px 18px; overflow:auto; max-height:calc(100vh - 175px); }
#${PANEL_ID} .cp-pane { display:none; }
#${PANEL_ID} .cp-pane.active { display:block; }
#${PANEL_ID} .cp-current { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:15px; }
#${PANEL_ID} .cp-current-main { min-width:0; }
#${PANEL_ID} .cp-eyebrow { color:#988a77; font:italic 11px Georgia,serif; letter-spacing:1.6px; margin-bottom:5px; }
#${PANEL_ID} .cp-title { color:#454239; font-size:23px; font-weight:600; line-height:1.35; overflow-wrap:anywhere; }
#${PANEL_ID} .cp-type { color:#948675; font-size:12px; margin-top:4px; }
#${PANEL_ID} .cp-status { flex:none; padding:6px 10px; border-radius:3px; background:#e6dfd1; color:#756b5e; font:11px Arial,sans-serif; }
#${PANEL_ID} .cp-status.playing { background:#e2eadf; color:#527552; }
#${PANEL_ID} .cp-status.paused { background:#f1e4ce; color:#9a7136; }
#${PANEL_ID} .cp-progress { display:grid; grid-template-columns:62px minmax(80px,1fr) 62px; gap:11px; align-items:center; margin:8px 2px 18px; color:#655d51; font:16px Georgia,serif; font-variant-numeric:tabular-nums; }
#${PANEL_ID} .cp-seek { appearance:none; width:100%; height:7px; border-radius:99px; outline:none; cursor:pointer; background:linear-gradient(90deg,#b96f67 0%,#b96f67 var(--progress,0%),#d8cdbd var(--progress,0%),#d8cdbd 100%); }
#${PANEL_ID} .cp-seek::-webkit-slider-thumb { appearance:none; width:16px;height:16px;border-radius:50%;background:#f8f0e3;border:3px solid #b96f67; }
#${PANEL_ID} .cp-seek::-moz-range-thumb { width:11px;height:11px;border-radius:50%;background:#f8f0e3;border:3px solid #b96f67; }
#${PANEL_ID} .cp-controls { display:flex; justify-content:center; align-items:center; gap:10px; flex-wrap:wrap; }
#${PANEL_ID} .cp-control { min-width:52px;height:48px;padding:0 13px;border:0;border-radius:5px;background:#7d9299;color:#fff8ef;font:19px Arial,sans-serif;cursor:pointer;box-shadow:0 2px 4px #665a461c; }
#${PANEL_ID} .cp-control.primary { width:66px;height:66px;border-radius:50%;background:#b96f67;font-size:26px; }
#${PANEL_ID} .cp-control:hover, #${PANEL_ID} .cp-action:hover { filter:brightness(1.06); }
#${PANEL_ID} .cp-speed { display:flex; align-items:center; gap:8px; padding-left:10px; color:#726859; font-size:13px; }
#${PANEL_ID} .cp-speed select { min-width:91px;padding:9px 10px;border:1px solid #cbbda8;border-radius:4px;background:#eee4d4;color:#514a40;cursor:pointer; }
#${PANEL_ID} .cp-actions { display:flex; align-items:center; justify-content:center; gap:9px; flex-wrap:wrap; margin-top:17px; }
#${PANEL_ID} .cp-action { padding:9px 13px;border:1px solid #cbbda8;border-radius:4px;background:#eee4d4;color:#62594d;font-size:12px;cursor:pointer; }
#${PANEL_ID} .cp-action.active { border-color:#b96f67;background:#b96f67;color:#fff8ef; }
#${PANEL_ID} .cp-note { margin-top:13px;padding-top:10px;border-top:1px solid #ded2c1;color:#918573;font-size:11px;line-height:1.6; }
#${PANEL_ID} .cp-list { display:flex;flex-direction:column;gap:5px;max-height:390px;overflow:auto;padding-right:4px; }
#${PANEL_ID} .cp-row { display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;padding:10px 12px;border:1px solid #ded4c5;border-radius:4px;background:#f8f2e8;color:#5b554b;text-align:left;cursor:pointer; }
#${PANEL_ID} .cp-row:hover { background:#efe5d7;border-color:#cbb99f; }
#${PANEL_ID} .cp-row.active { background:#e9d9d0;border-color:#c28a7e;color:#784f49; }
#${PANEL_ID} .cp-row.done .cp-row-name::before { content:'✓ ';color:#638363; }
#${PANEL_ID} .cp-row-name { min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px; }
#${PANEL_ID} .cp-row-meta { flex:none;color:#9b8d7b;font-size:11px; }
#${PANEL_ID} .cp-log-list { display:flex;flex-direction:column;gap:5px;max-height:390px;overflow:auto; }
#${PANEL_ID} .cp-log { display:grid;grid-template-columns:72px 48px minmax(0,1fr);gap:8px;padding:7px 9px;border-bottom:1px solid #e3d9ca;font:12px/1.5 Arial,sans-serif; }
#${PANEL_ID} .cp-log time { color:#9b907f; }
#${PANEL_ID} .cp-log .level { font-weight:700; }
#${PANEL_ID} .cp-log.info .level { color:#607f89; }
#${PANEL_ID} .cp-log.success .level { color:#54815e; }
#${PANEL_ID} .cp-log.warn .level { color:#b27b36; }
#${PANEL_ID} .cp-log.error .level { color:#b85e58; }
#${PANEL_ID} .cp-setting-list { display:grid;grid-template-columns:1fr 1fr;gap:9px; }
#${PANEL_ID} .cp-setting { display:flex;align-items:center;justify-content:space-between;gap:10px;padding:11px 12px;border:1px solid #ded4c5;border-radius:4px;background:#f8f2e8; }
#${PANEL_ID} .cp-setting strong { display:block;color:#5c554b;font-size:13px;font-weight:600; }
#${PANEL_ID} .cp-setting small { display:block;margin-top:3px;color:#9b907f;font-size:10px;line-height:1.4; }
#${PANEL_ID} .cp-switch { position:relative;flex:none;width:38px;height:21px;border:0;border-radius:20px;background:#c8bdad;cursor:pointer; }
#${PANEL_ID} .cp-switch::after { content:'';position:absolute;top:3px;left:3px;width:15px;height:15px;border-radius:50%;background:#fff;transition:transform .18s; }
#${PANEL_ID} .cp-switch.on { background:#81979b; }
#${PANEL_ID} .cp-switch.on::after { transform:translateX(17px); }
#${PANEL_ID} .cp-record-grid { display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin-bottom:14px; }
#${PANEL_ID} .cp-stat { padding:13px;border:1px solid #ded4c5;background:#f8f2e8;border-radius:4px; }
#${PANEL_ID} .cp-stat-label { color:#998b79;font-size:11px; }
#${PANEL_ID} .cp-stat-value { margin-top:6px;color:#514b42;font-size:20px;font-weight:700;font-variant-numeric:tabular-nums; }
#${PANEL_ID} .cp-record-list { display:flex;flex-direction:column;gap:5px;max-height:250px;overflow:auto; }
#${PANEL_ID} .cp-record-item { display:flex;justify-content:space-between;gap:12px;padding:8px 10px;border-bottom:1px solid #e2d8c9;color:#6e6559;font-size:12px; }
#${PANEL_ID} .cp-empty { padding:22px 12px;text-align:center;color:#9b907f;font-size:12px; }
#${PANEL_ID} .cp-foot { display:flex;justify-content:space-between;gap:10px;margin-top:12px;padding-top:10px;border-top:1px solid #ded2c1;color:#9b907f;font:10px Arial,sans-serif; }
#${PANEL_ID}.collapsed { width:280px; }
#${PANEL_ID}.collapsed .cp-tabs, #${PANEL_ID}.collapsed .cp-body { display:none; }
#${DOCK_ID} { position:fixed;right:20px;bottom:22px;z-index:2147483001;width:48px;height:48px;border:1px solid #cbbda8;border-radius:50%;background:#b96f67;color:#fff8ef;box-shadow:0 5px 16px #43392c40;font:600 13px Georgia,serif;cursor:pointer;display:none; }
@media(max-width:650px) {
#${PANEL_ID} { top:8px;right:8px;width:calc(100vw - 16px);max-height:calc(100vh - 16px); }
#${PANEL_ID} .cp-body { padding:13px 12px;max-height:calc(100vh - 155px); }
#${PANEL_ID} .cp-title { font-size:18px; }
#${PANEL_ID} .cp-setting-list { grid-template-columns:1fr; }
#${PANEL_ID} .cp-record-grid { grid-template-columns:1fr 1fr; }
#${PANEL_ID} .cp-tab { padding:8px 9px;font-size:12px; }
#${PANEL_ID} .cp-controls { gap:6px; }
#${PANEL_ID} .cp-control { min-width:42px;height:42px;padding:0 9px; }
#${PANEL_ID} .cp-control.primary { width:58px;height:58px; }
#${PANEL_ID} .cp-progress { grid-template-columns:49px minmax(40px,1fr) 49px;gap:6px;font-size:13px; }
}
`);
}
function createPanel() {
if ($('#' + PANEL_ID)) return;
installStyles();
const panel = document.createElement('section');
panel.id = PANEL_ID;
panel.innerHTML = `
COURSE PLAYER · ${VERSION}
NOW PLAYING
等待识别当前课件
课程播放器已就绪
待机
00:00
00:00
提示:首次自动播放可能受浏览器权限限制。若被拦截,请手动播放一次。
COURSE CONTENTS
课件队列
点击条目可切换到对应课件
LOCAL RECORDS
本地学习记录
仅保存在当前浏览器,不会向课程平台伪造数据
ACTIVITY LOG
运行日志
播放、切换与异常信息
PREFERENCES
播放设置
设置会自动保存在当前浏览器
高倍速可能不被网站播放器支持,也可能影响页面自身的进度判定。设置仅控制本地播放器行为。
`;
document.body.appendChild(panel);
const dock = document.createElement('button');
dock.id = DOCK_ID;
dock.type = 'button';
dock.textContent = 'CP';
dock.title = '打开课程播放器';
document.body.appendChild(dock);
panel.addEventListener('click', event => {
const tabButton = event.target.closest('[data-tab]');
if (tabButton) setTab(tabButton.dataset.tab);
});
$('#cp-collapse', panel).addEventListener('click', () => {
state.collapsed = !state.collapsed;
panel.classList.toggle('collapsed', state.collapsed);
$('#cp-collapse', panel).textContent = state.collapsed ? '+' : '−';
});
$('#cp-hide', panel).addEventListener('click', () => {
state.hidden = true;
panel.style.display = 'none';
dock.style.display = 'block';
});
dock.addEventListener('click', () => {
state.hidden = false;
panel.style.display = 'block';
dock.style.display = 'none';
});
$('#cp-play', panel).addEventListener('click', togglePlayback);
$('#cp-prev', panel).addEventListener('click', () => navigateRow(-1));
$('#cp-next', panel).addEventListener('click', () => navigateRow(1));
$('#cp-speed-select', panel).addEventListener('change', event => setSpeed(Number(event.target.value)));
$('#cp-auto', panel).addEventListener('click', toggleAutoContinue);
$('#cp-list', panel).addEventListener('click', () => setTab('queue'));
$('#cp-seek', panel).addEventListener('input', onSeekInput);
$('#cp-seek', panel).addEventListener('change', onSeekChange);
$('#cp-timer-toggle', panel).addEventListener('click', toggleTimer);
$('#cp-mark', panel).addEventListener('click', markCurrentComplete);
$('#cp-clear-logs', panel).addEventListener('click', () => { state.logs = []; renderLogs(); });
$('#cp-reset', panel).addEventListener('click', resetSettings);
renderSettings();
renderPanel();
makeDraggable(panel, $('#cp-drag', panel));
}
function makeDraggable(panel, handle) {
let dragging = false;
let startX = 0, startY = 0, startLeft = 0, startTop = 0;
handle.addEventListener('pointerdown', event => {
if (event.target.closest('button')) return;
const rect = panel.getBoundingClientRect();
dragging = true;
startX = event.clientX; startY = event.clientY;
startLeft = rect.left; startTop = rect.top;
handle.setPointerCapture?.(event.pointerId);
});
handle.addEventListener('pointermove', event => {
if (!dragging) return;
const left = Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, startLeft + event.clientX - startX));
const top = Math.max(0, Math.min(window.innerHeight - 45, startTop + event.clientY - startY));
panel.style.left = `${left}px`; panel.style.top = `${top}px`; panel.style.right = 'auto';
});
const stop = () => { dragging = false; };
handle.addEventListener('pointerup', stop);
handle.addEventListener('pointercancel', stop);
}
function setTab(tab) {
state.tab = tab;
$$('.cp-tab', $('#' + PANEL_ID)).forEach(button => button.classList.toggle('active', button.dataset.tab === tab));
$$('.cp-pane', $('#' + PANEL_ID)).forEach(pane => pane.classList.toggle('active', pane.dataset.pane === tab));
if (tab === 'queue') renderQueue();
if (tab === 'records') renderRecords();
if (tab === 'logs') renderLogs();
}
function getRows() {
let rows = $$('.courseware-row').filter(row => row.querySelector('.courseware-name'));
if (!rows.length) rows = $$('.third-level-box');
if (!rows.length) rows = $$('#chapters-list li').filter(row => /item\s+ref_\d+/.test(row.className));
return [...new Set(rows)].filter(row => !row.closest(`#${PANEL_ID}`));
}
function getRowName(row) {
return row?.querySelector('.courseware-name, .file-name, .title, .name')?.textContent?.trim()
|| row?.getAttribute('title')?.trim()
|| row?.textContent?.trim().replace(/\s+/g, ' ').slice(0, 100)
|| '未命名课件';
}
function getRowType(row) {
const icon = row?.querySelector('.file-icon, [class*="icon"], [class*="type"]');
const value = `${icon?.textContent || ''} ${icon?.className || ''} ${row?.className || ''}`.trim();
if (/video|视频|mp4/i.test(value)) return '视频';
if (/ppt|pdf|文档|图书|图片|课件/i.test(value)) return '课件';
if (/测验|考试|作业|讨论/i.test(getRowName(row))) return '测验/作业';
return '课程内容';
}
function getRowKey(row) {
return `${state.rows.indexOf(row)}:${getRowName(row)}`;
}
function getActiveRow() {
const rows = state.rows.length ? state.rows : getRows();
return rows.find(row => row.classList.contains('active') || row.classList.contains('current') || row.classList.contains('is-active') || row.getAttribute('aria-current') === 'true') || null;
}
function getVideoElement() {
return $('video');
}
function getVideoSource(video) {
if (!video) return '';
return video.currentSrc || video.src || $('source', video)?.src || '';
}
function setNotice(message) {
const notice = $('#cp-notice');
if (notice) notice.textContent = message;
}
function getVisiblePlayButton() {
const selectors = [
'.video-overlay .play-btn',
'.dplayer-play-icon',
'.dplayer-video-wrap .dplayer-play-icon',
'.xgplayer-start',
'.xgplayer-icon-play',
'button[aria-label*="播放"]',
'button[title*="播放"]'
];
for (const selector of selectors) {
const button = $(selector);
if (!button || button.closest(`#${PANEL_ID}`)) continue;
const style = getComputedStyle(button);
const rect = button.getBoundingClientRect();
if (style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 2 && rect.height > 2) return button;
}
return null;
}
function attachVideo(video) {
if (video === state.video) return;
if (state.video) {
state.video.removeEventListener('timeupdate', onVideoUpdate);
state.video.removeEventListener('durationchange', onVideoUpdate);
state.video.removeEventListener('loadedmetadata', onVideoReady);
state.video.removeEventListener('canplay', onVideoReady);
state.video.removeEventListener('play', onVideoUpdate);
state.video.removeEventListener('pause', onVideoUpdate);
state.video.removeEventListener('ended', onVideoEnded);
}
if (state.pauseRecoveryTimer) clearInterval(state.pauseRecoveryTimer);
state.pauseRecoveryTimer = null;
state.video = video || null;
state.activeMediaSource = getVideoSource(video);
if (state.video) {
try { state.video.playbackRate = state.settings.speed; } catch (_) {}
state.video.addEventListener('timeupdate', onVideoUpdate);
state.video.addEventListener('durationchange', onVideoUpdate);
state.video.addEventListener('loadedmetadata', onVideoReady);
state.video.addEventListener('canplay', onVideoReady);
state.video.addEventListener('play', onVideoUpdate);
state.video.addEventListener('pause', onVideoUpdate);
state.video.addEventListener('ended', onVideoEnded);
if (state.settings.pauseRecovery) startPauseRecovery();
}
renderVideo();
}
function onVideoUpdate() { renderVideo(); }
function onVideoReady() {
try { if (state.video) state.video.playbackRate = state.settings.speed; } catch (_) {}
renderVideo();
}
function startPauseRecovery() {
if (state.pauseRecoveryTimer) clearInterval(state.pauseRecoveryTimer);
state.pauseRecoveryTimer = setInterval(() => {
const video = state.video;
if (!state.settings.pauseRecovery || !state.settings.autoContinue || !video || video.ended || !video.paused) return;
if (video.readyState >= 2) video.play().catch(() => {});
}, 2500);
}
function markRowComplete(row, method = 'video-ended') {
if (!row || !state.record) return;
const key = getRowKey(row);
state.record.completed[key] = { name: getRowName(row), at: Date.now(), method };
state.record.lastItem = getRowName(row);
saveRecord();
renderRecords();
renderQueue();
}
function onVideoEnded() {
const row = state.activeRow || getActiveRow();
log('success', `视频播放结束:${row ? getRowName(row) : '当前视频'}`);
if (row) markRowComplete(row, 'video-ended');
if (state.settings.autoContinue) advanceToNextVideo();
}
async function startCurrentVideo(fromUserAction = false) {
const token = ++state.playAttemptToken;
let video = getVideoElement();
if (!video) {
setNotice('当前课件未检测到可控制的视频');
return false;
}
attachVideo(video);
try { video.playbackRate = state.settings.speed; } catch (_) {}
if (!video.paused && !video.ended) {
setNotice(`正在播放 · ${state.settings.speed}×`);
return true;
}
if (video.ended) {
try { video.currentTime = 0; } catch (_) {}
}
if (video.readyState < 2) {
await new Promise(resolve => {
let finished = false;
const done = () => {
if (finished) return;
finished = true;
video.removeEventListener('canplay', done);
video.removeEventListener('loadeddata', done);
resolve();
};
video.addEventListener('canplay', done, { once: true });
video.addEventListener('loadeddata', done, { once: true });
setTimeout(done, 6000);
});
}
if (token !== state.playAttemptToken) return false;
try {
video.playbackRate = state.settings.speed;
await video.play();
if (!video.paused) {
setNotice(`播放中 · ${state.settings.speed}×`);
log('success', `已启动视频,倍速 ${state.settings.speed}×`);
renderVideo();
return true;
}
} catch (error) {
// 若站点播放器必须通过自己的封面按钮启动,只尝试点击一次,避免双击后反向暂停。
const playButton = getVisiblePlayButton();
if (playButton) {
try {
playButton.click();
await wait(700);
video = getVideoElement() || video;
attachVideo(video);
if (!video.paused) {
try { video.playbackRate = state.settings.speed; } catch (_) {}
setNotice(`已通过网页播放器启动 · ${state.settings.speed}×`);
log('success', '已通过网页播放器启动视频');
return true;
}
} catch (_) {}
}
const reason = error?.name === 'NotAllowedError'
? '浏览器阻止了自动播放,请先手动点击网页播放器一次'
: error?.name === 'NotSupportedError'
? '视频资源暂不可播放,等待页面加载后重试'
: '播放器未能启动,请检查网页播放器状态';
setNotice(reason);
if (!fromUserAction) log('warn', reason);
else log('warn', `播放失败:${reason}`);
return false;
}
setNotice('播放器没有确认开始播放');
return false;
}
async function togglePlayback() {
const video = getVideoElement();
if (!video) { setNotice('当前页面没有检测到视频'); return; }
attachVideo(video);
if (video.paused || video.ended) await startCurrentVideo(true);
else {
video.pause();
setNotice('已暂停');
log('info', '用户暂停播放');
}
renderVideo();
}
function setSpeed(speed) {
if (!SPEEDS.includes(speed)) return;
state.settings.speed = speed;
if (state.settings.rememberSpeed) saveSettings();
const video = getVideoElement();
if (video) {
try { video.playbackRate = speed; } catch (_) {}
}
const select = $('#cp-speed-select');
if (select) select.value = String(speed);
setNotice(video ? `播放倍速已设为 ${speed}×` : `已保存 ${speed}×,下个视频加载后应用`);
log('info', `播放倍速设置为 ${speed}×`);
}
async function toggleAutoContinue() {
state.settings.autoContinue = !state.settings.autoContinue;
saveSettings();
renderAutoState();
if (state.settings.autoContinue) {
log('info', '自动连播已开启');
setNotice('自动连播已开启,尝试启动当前视频');
const ok = await startCurrentVideo(true);
if (!ok) setNotice('自动连播已开启;如浏览器拦截,请手动播放当前视频一次');
} else {
state.advanceToken++;
log('info', '自动连播已关闭');
setNotice('自动连播已关闭');
}
}
function renderAutoState() {
const button = $('#cp-auto');
if (!button) return;
button.classList.toggle('active', state.settings.autoContinue);
button.setAttribute('aria-pressed', String(state.settings.autoContinue));
button.textContent = `自动连播:${state.settings.autoContinue ? '开' : '关'}`;
}
function isAssessmentRow(row) {
return /测验|作业|考试|讨论|章节测试|随堂测验/.test(`${getRowName(row)} ${getRowType(row)}`);
}
function isLikelyNonVideo(row) {
const text = `${getRowName(row)} ${getRowType(row)} ${row?.className || ''}`;
return /ppt|pdf|文档|图书|阅读|图片|附件|课件/.test(text) && !/视频|video|mp4/i.test(text);
}
async function waitForTargetVideo(targetRow, previousVideo, previousSource, token, timeout = 14000) {
const start = Date.now();
let stableReadyAt = 0;
while (Date.now() - start < timeout) {
if (!state.settings.autoContinue || token !== state.advanceToken) return null;
await wait(250);
const active = getActiveRow();
const activeOk = !active || active === targetRow;
const video = getVideoElement();
const source = getVideoSource(video);
const changed = !!video && (video !== previousVideo || (!!source && source !== previousSource));
const ready = !!video && video.readyState >= 2 && (!Number.isFinite(video.duration) || video.duration > 0);
if (activeOk && ready && (changed || Date.now() - start > 1800)) {
if (!stableReadyAt) stableReadyAt = Date.now();
if (Date.now() - stableReadyAt > 500) return video;
} else stableReadyAt = 0;
}
return null;
}
async function advanceToNextVideo() {
if (!state.settings.autoContinue || state.advancing) return;
state.advancing = true;
const token = ++state.advanceToken;
try {
state.rows = getRows();
const rows = state.rows;
const current = getActiveRow() || state.activeRow;
const currentIndex = rows.indexOf(current);
const previousVideo = getVideoElement();
const previousSource = getVideoSource(previousVideo);
for (let index = Math.max(-1, currentIndex) + 1; index < rows.length; index++) {
if (!state.settings.autoContinue || token !== state.advanceToken) return;
const row = rows[index];
if (state.settings.skipAssessments && isAssessmentRow(row)) {
log('info', `未自动进入测验/作业:${getRowName(row)}`);
continue;
}
if (state.settings.skipNonVideo && isLikelyNonVideo(row)) {
log('info', `连播略过非视频课件:${getRowName(row)}`);
continue;
}
setNotice(`正在切换:${getRowName(row)}`);
log('info', `切换到下一项:${getRowName(row)}`);
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.click();
const candidate = await waitForTargetVideo(row, previousVideo, previousSource, token, 12000);
if (!state.settings.autoContinue || token !== state.advanceToken) return;
if (!candidate) {
log('warn', `该项未检测到可播放视频,继续查找:${getRowName(row)}`);
continue;
}
state.rows = getRows();
state.activeRow = getActiveRow() || row;
attachVideo(candidate);
renderPanel();
// 只调用一次启动流程;启动失败时不伪报成功,也不重复点击播放按钮。
const started = await startCurrentVideo(false);
if (started) {
setNotice(`已切换并开始播放:${getRowName(state.activeRow)}`);
log('success', `已开始下一节:${getRowName(state.activeRow)}`);
} else {
setNotice(`已切换到「${getRowName(state.activeRow)}」,但浏览器未允许自动播放`);
}
return;
}
if (state.settings.autoContinue && token === state.advanceToken) {
setNotice('后面没有检测到可自动连播的视频');
log('info', '当前目录中没有更多可连播的视频');
}
} finally {
state.advancing = false;
}
}
async function selectRow(row, autoPlay = true) {
if (!row) return;
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.click();
state.activeRow = row;
renderQueue();
setNotice(`已选择:${getRowName(row)}`);
await wait(700);
refreshPageData();
if (autoPlay && state.settings.autoContinue && state.settings.autoPlayOnSwitch) {
await wait(500);
const ok = await startCurrentVideo(false);
if (!ok) setNotice('课件已切换;播放器尚未就绪或自动播放被拦截');
}
}
async function navigateRow(offset) {
state.rows = getRows();
const index = state.rows.indexOf(state.activeRow || getActiveRow());
const next = Math.max(0, Math.min(state.rows.length - 1, (index < 0 ? (offset > 0 ? -1 : state.rows.length) : index) + offset));
const row = state.rows[next];
if (!row) { setNotice('没有可切换的课件'); return; }
await selectRow(row, true);
}
function renderQueue() {
const container = $('#cp-queue');
if (!container) return;
state.rows = getRows();
if (!state.rows.length) {
container.innerHTML = '暂未识别到课件目录。请先进入课程学习页面。
';
return;
}
container.replaceChildren();
state.rows.forEach((row, index) => {
const button = document.createElement('button');
const key = getRowKey(row);
const isDone = Boolean(state.record?.completed?.[key]);
button.type = 'button';
button.className = `cp-row${row === state.activeRow ? ' active' : ''}${isDone ? ' done' : ''}`;
const name = document.createElement('span');
name.className = 'cp-row-name';
name.textContent = `${index + 1}. ${getRowName(row)}`;
const meta = document.createElement('span');
meta.className = 'cp-row-meta';
meta.textContent = isDone ? '本地已完成' : getRowType(row);
button.append(name, meta);
button.addEventListener('click', () => selectRow(row, true));
container.appendChild(button);
});
}
function renderLogs() {
const container = $('#cp-log-list');
if (!container) return;
if (!state.logs.length) {
container.innerHTML = '等待运行信息…
';
return;
}
container.replaceChildren();
state.logs.slice().reverse().forEach(item => {
const row = document.createElement('div');
row.className = `cp-log ${item.level}`;
const time = document.createElement('time'); time.textContent = item.time;
const level = document.createElement('span'); level.className = 'level';
level.textContent = ({ info: '信息', success: '成功', warn: '提醒', error: '错误' })[item.level] || '日志';
const message = document.createElement('span'); message.textContent = item.message;
row.append(time, level, message);
container.appendChild(row);
});
}
function renderSettings() {
const container = $('#cp-settings-list');
if (!container) return;
const options = [
['autoPlayOnSwitch', '切换课件后自动播放', '开启自动连播时,切换到新视频后尝试启动'],
['skipAssessments', '不自动进入测验/作业', '自动连播查找下一节时避开测验、考试、讨论和作业'],
['skipNonVideo', '连播时略过非视频课件', '自动连播只查找视频;PPT、PDF 等仍可在队列中手动打开'],
['pauseRecovery', '恢复意外暂停', '仅在自动连播开启时尝试恢复意外暂停'],
['rememberSpeed', '记住倍速选择', '保存上次选择的播放速度']
];
container.replaceChildren();
options.forEach(([key, title, description]) => {
const item = document.createElement('div'); item.className = 'cp-setting';
const text = document.createElement('div');
const strong = document.createElement('strong'); strong.textContent = title;
const small = document.createElement('small'); small.textContent = description;
text.append(strong, small);
const toggle = document.createElement('button');
toggle.type = 'button'; toggle.className = `cp-switch${state.settings[key] ? ' on' : ''}`;
toggle.setAttribute('aria-pressed', String(Boolean(state.settings[key])));
toggle.addEventListener('click', () => {
state.settings[key] = !state.settings[key];
if (key === 'rememberSpeed' && state.settings.rememberSpeed) saveSettings();
if (key === 'pauseRecovery') {
if (state.settings.pauseRecovery) startPauseRecovery();
else if (state.pauseRecoveryTimer) { clearInterval(state.pauseRecoveryTimer); state.pauseRecoveryTimer = null; }
}
saveSettings(); renderSettings();
log('info', `${title}:${state.settings[key] ? '开启' : '关闭'}`);
});
item.append(text, toggle); container.appendChild(item);
});
}
function resetSettings() {
state.settings = { ...defaults };
saveSettings();
const select = $('#cp-speed-select');
if (select) select.value = String(state.settings.speed);
if (state.pauseRecoveryTimer) clearInterval(state.pauseRecoveryTimer);
state.pauseRecoveryTimer = null;
renderSettings(); renderAutoState();
setSpeed(state.settings.speed);
log('info', '播放设置已恢复默认');
}
function markCurrentComplete() {
const row = state.activeRow || getActiveRow();
if (!row) { setNotice('尚未识别当前课件'); return; }
markRowComplete(row, 'manual');
setNotice('已添加本地完成标记');
log('success', `已手动标记:${getRowName(row)}`);
}
function toggleTimer() {
if (state.timerRunning) {
state.timerRunning = false;
state.lastTick = 0;
if (state.timerId) clearInterval(state.timerId);
state.timerId = null;
saveRecord();
log('info', '本地计时已暂停');
} else {
state.timerRunning = true;
state.lastTick = Date.now();
if (state.timerId) clearInterval(state.timerId);
state.timerId = setInterval(tickTimer, 1000);
log('info', '本地计时已开始');
}
renderRecords();
}
function tickTimer() {
if (!state.timerRunning) return;
const now = Date.now();
if (document.visibilityState === 'visible' && isCoursePage() && state.lastTick) {
state.elapsedMs += Math.max(0, Math.min(now - state.lastTick, 2000));
}
state.lastTick = now;
if (now % 10000 < 1000) saveRecord();
renderRecords();
}
function renderRecords() {
const total = $('#cp-total-time');
const count = $('#cp-completed-count');
const rowCount = $('#cp-row-count');
const timerButton = $('#cp-timer-toggle');
const list = $('#cp-record-list');
if (total) total.textContent = formatTime(state.elapsedMs / 1000);
if (count) count.textContent = String(Object.keys(state.record?.completed || {}).length);
if (rowCount) rowCount.textContent = String(state.rows.length);
if (timerButton) {
timerButton.textContent = state.timerRunning ? '暂停计时' : '开始计时';
timerButton.classList.toggle('active', state.timerRunning);
}
if (!list) return;
const entries = Object.values(state.record?.completed || {}).sort((a, b) => b.at - a.at);
if (!entries.length) { list.innerHTML = '暂无本地记录。视频自然播放结束后会自动记录,也可以手动标记。
'; return; }
list.replaceChildren();
entries.forEach(entry => {
const row = document.createElement('div'); row.className = 'cp-record-item';
const name = document.createElement('span'); name.textContent = entry.name || '课件';
const meta = document.createElement('span'); meta.textContent = `${entry.method === 'manual' ? '手动标记' : '视频结束'} · ${new Date(entry.at).toLocaleString()}`;
row.append(name, meta); list.appendChild(row);
});
}
function onSeekInput(event) {
const video = state.video;
if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return;
state.seeking = true;
const value = Number(event.target.value);
const percent = value / video.duration * 100;
event.target.style.setProperty('--progress', `${percent}%`);
$('#cp-current-time').textContent = formatTime(value);
}
function onSeekChange(event) {
const video = state.video;
if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return;
video.currentTime = Number(event.target.value);
state.seeking = false;
renderVideo();
}
function renderVideo() {
const video = state.video;
const current = $('#cp-current-time');
const durationEl = $('#cp-duration');
const seek = $('#cp-seek');
const play = $('#cp-play');
const status = $('#cp-status');
if (!current || !durationEl || !seek || !play || !status) return;
if (!video) {
current.textContent = '00:00'; durationEl.textContent = '00:00';
seek.max = '100'; seek.value = '0'; seek.style.setProperty('--progress', '0%');
play.textContent = '▶'; status.textContent = '待机'; status.className = 'cp-status';
return;
}
const d = Number.isFinite(video.duration) ? video.duration : 0;
const t = Number.isFinite(video.currentTime) ? video.currentTime : 0;
current.textContent = formatTime(t); durationEl.textContent = formatTime(d);
seek.max = String(d || 100);
if (!state.seeking) seek.value = String(t);
seek.style.setProperty('--progress', `${d > 0 ? Math.min(100, t / d * 100) : 0}%`);
const playing = !video.paused && !video.ended;
play.textContent = playing ? 'Ⅱ' : '▶';
status.textContent = playing ? '播放中' : video.ended ? '已结束' : '已暂停';
status.className = `cp-status${playing ? ' playing' : video.ended ? '' : ' paused'}`;
}
function renderPanel() {
const title = $('#cp-title');
const type = $('#cp-type');
const speed = $('#cp-speed-select');
if (!title || !type) return;
state.rows = getRows();
state.activeRow = getActiveRow() || state.activeRow;
title.textContent = state.activeRow ? getRowName(state.activeRow) : '等待识别当前课件';
type.textContent = state.activeRow ? `${getRowType(state.activeRow)} · COURSE MATERIAL` : '课程播放器已就绪';
if (speed) speed.value = String(state.settings.speed);
renderAutoState(); renderVideo();
if (state.tab === 'queue') renderQueue();
if (state.tab === 'records') renderRecords();
}
function refreshPageData() {
state.rows = getRows();
const active = getActiveRow();
if (active && active !== state.activeRow) {
state.lastActiveRow = state.activeRow;
state.activeRow = active;
if (state.settings.autoContinue && state.settings.autoPlayOnSwitch && !state.advancing && state.lastActiveRow) {
const changedRow = active;
setTimeout(() => {
if (state.activeRow === changedRow && state.settings.autoContinue && !state.advancing) {
startCurrentVideo(false);
}
}, 800);
}
} else if (!state.activeRow && active) state.activeRow = active;
const video = getVideoElement();
if (video && video !== state.video) attachVideo(video);
else if (video && getVideoSource(video) !== state.activeMediaSource) attachVideo(video);
if (state.activeRow && state.record) state.record.lastItem = getRowName(state.activeRow);
renderPanel();
}
function queueRefresh() {
if (state.refreshQueued) return;
state.refreshQueued = true;
requestAnimationFrame(() => { state.refreshQueued = false; refreshPageData(); });
}
function observePage() {
if (state.observer) state.observer.disconnect();
state.observer = new MutationObserver(() => queueRefresh());
state.observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'src', 'aria-current'] });
}
function onRouteChange() {
if (state.lastPath === location.pathname) return;
state.lastPath = location.pathname;
loadRecord();
state.rows = [];
state.activeRow = null;
state.lastActiveRow = null;
state.video = null;
queueRefresh();
if (!isCoursePage() && state.timerRunning) toggleTimer();
}
function hookHistory() {
const push = history.pushState;
const replace = history.replaceState;
history.pushState = function (...args) { const result = push.apply(this, args); onRouteChange(); return result; };
history.replaceState = function (...args) { const result = replace.apply(this, args); onRouteChange(); return result; };
window.addEventListener('popstate', onRouteChange);
}
function init() {
loadSettings();
loadRecord();
createPanel();
hookHistory();
refreshPageData();
observePage();
setTimeout(refreshPageData, 1000);
setTimeout(refreshPageData, 2800);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible' && state.timerRunning) state.lastTick = Date.now();
});
log('success', `课程播放器 v${VERSION} 已加载`);
if (!isCoursePage()) log('info', '请进入课程学习页面后使用课件队列与播放控制');
}
if (document.body) init();
else window.addEventListener('DOMContentLoaded', init, { once: true });
})();