// ==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
本地学习记录
仅保存在当前浏览器,不会向课程平台伪造数据
累计计时
0:00
本地完成标记
0
目录课件数
0
暂无本地记录
ACTIVITY LOG
运行日志
播放、切换与异常信息
等待运行信息…
PREFERENCES
播放设置
设置会自动保存在当前浏览器
高倍速可能不被网站播放器支持,也可能影响页面自身的进度判定。设置仅控制本地播放器行为。
重庆高教在线 · 本地播放控制v${VERSION}
`; 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 }); })();