// ==UserScript== // @name 重庆高教在线播放器 // @namespace https://www.cqooc.com/ // @version 2.1.0 // @description 重庆高教在线课程播放控制:视频播放/暂停、倍速、进度、课件目录、本地计时与学习记录。 // @match https://www.cqooc.com/* // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @run-at document-idle // @license MIT // ==/UserScript== (() => { 'use strict'; const VERSION = '2.1.0'; const STORE_PREFIX = 'cqooc-study-helper-v2:'; const PANEL_ID = 'cqooc-player'; const COURSE_PATHS = ['/course/detail/courseStudy', '/learn/mooc/structure']; const state = { rows: [], activeRow: null, video: null, studying: false, collapsed: false, listOpen: false, elapsedMs: 0, lastTick: 0, timerId: null, observer: null, refreshQueued: false, courseKey: '', record: null, speed: 1, lastPath: location.pathname, seeking: false }; const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector)); const isCoursePage = () => COURSE_PATHS.includes(location.pathname); function getCourseKey() { const courseId = new URL(location.href).searchParams.get('id') || 'unknown-course'; return `${STORE_PREFIX}${courseId}`; } function loadRecord() { state.courseKey = getCourseKey(); const saved = GM_getValue(state.courseKey, null); state.record = saved && typeof saved === 'object' ? saved : { elapsedMs: 0, reviewed: {}, lastItem: '', updatedAt: 0 }; state.record.reviewed ||= {}; state.elapsedMs = Number(state.record.elapsedMs) || 0; } function saveRecord() { if (!state.record) return; state.record.elapsedMs = state.elapsedMs; state.record.updatedAt = Date.now(); GM_setValue(state.courseKey, state.record); } function formatDuration(value) { const total = Math.floor(Math.max(0, Number(value) || 0)); const hours = Math.floor(total / 3600); const minutes = Math.floor((total % 3600) / 60); const seconds = total % 60; return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` : `${minutes}:${String(seconds).padStart(2, '0')}`; } function formatClock(seconds) { return formatDuration(seconds); } function getRows() { return $$('.courseware-row').filter(row => row.querySelector('.courseware-name')); } function getRowName(row) { return row?.querySelector('.courseware-name')?.textContent?.trim() || '未命名课件'; } function getRowType(row) { return row?.querySelector('.file-icon')?.textContent?.trim() || '课件'; } function getRowKey(row) { const index = state.rows.indexOf(row); return `${index}:${getRowName(row)}`; } function getActiveRow() { return state.rows.find(row => row.classList.contains('active')) || null; } function getVideoElement() { return $('.dplayer video') || $('video'); } function setNotice(message) { const el = $('#ch-notice'); if (el) el.textContent = message; } function ensurePanel() { if ($('#' + PANEL_ID)) return; GM_addStyle(` #${PANEL_ID} { position: fixed; top: 86px; right: 22px; width: min(980px, calc(100vw - 32px)); z-index: 2147483000; color: #51483d; font-family: Georgia, "Noto Serif SC", "Songti SC", "SimSun", serif; background: radial-gradient(ellipse at 96% 8%, rgba(194,112,103,.13), transparent 25%), radial-gradient(ellipse at 4% 100%, rgba(111,137,148,.10), transparent 23%), repeating-linear-gradient(0deg, rgba(120,93,65,.018) 0, rgba(120,93,65,.018) 1px, transparent 1px, transparent 4px), linear-gradient(112deg, #f7eddd 0%, #f4e8d7 48%, #f8efdf 100%); border: 1px solid rgba(139,111,82,.24); border-radius: 9px 13px 10px 12px; box-shadow: 0 15px 38px rgba(72,55,39,.22), inset 0 0 0 5px rgba(255,255,255,.20); overflow: hidden; box-sizing: border-box; } #${PANEL_ID} * { box-sizing: border-box; } #${PANEL_ID}::before { content: ""; position: absolute; pointer-events: none; inset: 8px; border: 1px solid rgba(157,126,94,.13); border-radius: 6px 10px 7px 9px; } #${PANEL_ID} .ch-top { position: relative; display: flex; align-items: center; justify-content: space-between; padding: 12px 22px 5px; cursor: move; user-select: none; } #${PANEL_ID} .ch-brand { color: #8a7d6d; font-size: 12px; font-style: italic; letter-spacing: 2.2px; } #${PANEL_ID} .ch-top-actions { display:flex; align-items:center; gap:12px; } #${PANEL_ID} .ch-icon { border: 0; background: transparent; color: #62584c; font: 22px/1 Georgia, serif; cursor: pointer; padding: 3px 6px; } #${PANEL_ID} .ch-main { position: relative; padding: 6px 30px 22px; } #${PANEL_ID} .ch-title-row { display:flex; align-items:center; justify-content:space-between; gap:18px; margin: 0 0 16px; } #${PANEL_ID} .ch-title-wrap { display:flex; align-items:center; gap:15px; min-width:0; } #${PANEL_ID} .ch-title-mark { width:7px; height:50px; flex:0 0 7px; background:#bd756c; border-radius:2px; box-shadow: 1px 1px 0 rgba(255,255,255,.45); } #${PANEL_ID} .ch-title { color:#4d453b; font-size: clamp(21px, 2.5vw, 32px); font-weight:600; line-height:1.25; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } #${PANEL_ID} .ch-subtitle { color:#958777; font: italic 11px Georgia,serif; letter-spacing:1.5px; white-space:nowrap; } #${PANEL_ID} .ch-compass { position:relative; flex:0 0 88px; height:72px; display:flex; align-items:center; justify-content:center; color:rgba(105,98,85,.58); font:11px Georgia,serif; } #${PANEL_ID} .ch-compass .rose { font-size:45px; line-height:1; color:rgba(105,98,85,.45); } #${PANEL_ID} .ch-compass .north { position:absolute; top:0; } #${PANEL_ID} .ch-compass .south { position:absolute; bottom:0; } #${PANEL_ID} .ch-compass .west { position:absolute; left:0; } #${PANEL_ID} .ch-compass .east { position:absolute; right:0; } #${PANEL_ID} .ch-progress-row { display:grid; grid-template-columns: 74px minmax(80px,1fr) 74px; align-items:center; gap:14px; margin: 0 4px 20px; color:#5a5147; font: 21px Georgia,serif; font-variant-numeric:tabular-nums; } #${PANEL_ID} .ch-time { white-space:nowrap; } #${PANEL_ID} input[type="range"].ch-seek { appearance:none; width:100%; height:9px; border-radius:99px; background:linear-gradient(90deg,#b96f67 0%,#b96f67 var(--progress,0%),#d9cbb8 var(--progress,0%),#d9cbb8 100%); outline:none; cursor:pointer; } #${PANEL_ID} input[type="range"].ch-seek::-webkit-slider-thumb { appearance:none; width:19px; height:19px; border-radius:50%; background:#fbf3e7; border:3px solid #b96f67; box-shadow:0 1px 2px rgba(60,45,30,.18); } #${PANEL_ID} input[type="range"].ch-seek::-moz-range-thumb { width:14px; height:14px; border-radius:50%; background:#fbf3e7; border:3px solid #b96f67; } #${PANEL_ID} .ch-controls { display:flex; align-items:center; justify-content:center; gap:14px; flex-wrap:wrap; } #${PANEL_ID} .ch-control-btn { display:inline-flex; align-items:center; justify-content:center; min-width:58px; height:56px; padding:0 15px; border:0; border-radius:7px; background:#7d9299; color:#f8f1e6; font: 24px/1 Arial,sans-serif; cursor:pointer; box-shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 2px 3px rgba(70,57,44,.10); transition: transform .15s, filter .15s; } #${PANEL_ID} .ch-control-btn:hover { filter:brightness(1.06); transform:translateY(-1px); } #${PANEL_ID} .ch-control-btn:active { transform:translateY(1px); } #${PANEL_ID} .ch-control-btn.primary { width:82px; height:82px; border-radius:50%; background:#b96f67; font-size:33px; } #${PANEL_ID} .ch-control-btn.stop { font-size:22px; } #${PANEL_ID} .ch-divider { width:1px; height:62px; background:rgba(125,105,82,.32); margin:0 5px; } #${PANEL_ID} .ch-speed-wrap { display:flex; align-items:center; gap:10px; color:#655b4e; font-size:18px; } #${PANEL_ID} select.ch-speed { appearance:none; min-width:112px; padding:12px 34px 12px 17px; border:1px solid rgba(151,122,91,.25); border-radius:6px; color:#554b40; background:linear-gradient(145deg,#f5e8d5,#e9dac5); font:18px Georgia,serif; cursor:pointer; background-image:linear-gradient(45deg,transparent 50%,#75695a 50%),linear-gradient(135deg,#75695a 50%,transparent 50%),linear-gradient(145deg,#f5e8d5,#e9dac5); background-position:calc(100% - 17px) 50%,calc(100% - 12px) 50%,0 0; background-size:5px 5px,5px 5px,100% 100%; background-repeat:no-repeat; } #${PANEL_ID} .ch-list-btn { display:inline-flex; align-items:center; gap:11px; border:0; border-radius:6px; padding:15px 22px; color:#f7f0e5; background:#7d9299; font:18px "Noto Serif SC","Songti SC",serif; cursor:pointer; box-shadow:inset 0 1px 0 rgba(255,255,255,.24),0 2px 4px rgba(70,57,44,.12); } #${PANEL_ID} .ch-list-btn:hover { filter:brightness(1.06); } #${PANEL_ID} .ch-list-glyph { font:22px Arial,sans-serif; } #${PANEL_ID} .ch-utility { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:17px; padding-top:12px; border-top:1px solid rgba(139,111,82,.18); color:#8d806f; font-size:12px; } #${PANEL_ID} .ch-utility-left { display:flex; align-items:center; gap:14px; min-width:0; } #${PANEL_ID} .ch-run-btn, #${PANEL_ID} .ch-small-btn { border:1px solid rgba(145,115,83,.22); border-radius:5px; padding:7px 13px; color:#66594a; background:rgba(255,250,240,.55); font:13px "Noto Serif SC","Songti SC",serif; cursor:pointer; } #${PANEL_ID} .ch-run-btn.running { color:#fff8ef; background:#b96f67; border-color:#b96f67; } #${PANEL_ID} .ch-notice { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } #${PANEL_ID} .ch-list-panel { position:relative; display:none; margin:14px 2px 0; padding:12px 14px; border-top:1px dashed rgba(145,115,83,.35); background:rgba(255,250,241,.35); max-height:220px; overflow:auto; } #${PANEL_ID} .ch-list-panel.open { display:block; } #${PANEL_ID} .ch-row { display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; padding:9px 10px; border:0; border-bottom:1px solid rgba(145,115,83,.13); color:#5c5145; background:transparent; text-align:left; font:14px "Noto Serif SC","Songti SC",serif; cursor:pointer; } #${PANEL_ID} .ch-row:hover { background:rgba(185,111,103,.10); } #${PANEL_ID} .ch-row.active { color:#fff8ef; background:#b96f67; } #${PANEL_ID} .ch-row small { opacity:.75; white-space:nowrap; } #${PANEL_ID} .ch-empty { color:#8d806f; padding:10px; font-size:13px; } #${PANEL_ID}.collapsed { width:270px; } #${PANEL_ID}.collapsed .ch-main { display:none; } @media (max-width:680px) { #${PANEL_ID} { top:12px; right:8px; width:calc(100vw - 16px); } #${PANEL_ID} .ch-main { padding:5px 16px 16px; } #${PANEL_ID} .ch-compass { display:none; } #${PANEL_ID} .ch-title { font-size:21px; } #${PANEL_ID} .ch-controls { gap:8px; } #${PANEL_ID} .ch-control-btn { min-width:46px; height:46px; padding:0 10px; } #${PANEL_ID} .ch-control-btn.primary { width:66px; height:66px; } #${PANEL_ID} .ch-divider { display:none; } #${PANEL_ID} .ch-speed-wrap { font-size:14px; } #${PANEL_ID} .ch-list-btn { padding:12px 14px; font-size:15px; } #${PANEL_ID} .ch-progress-row { grid-template-columns:55px minmax(50px,1fr) 55px; gap:8px; font-size:16px; } } `); const panel = document.createElement('section'); panel.id = PANEL_ID; panel.innerHTML = `
COURSE PLAYER · STUDY NOTES
等待识别当前课件
COURSE MATERIAL
00:00 00:00
累计 0:00
就绪
正在读取课件目录…
`; document.body.appendChild(panel); $('#ch-collapse', panel).addEventListener('click', () => { state.collapsed = !state.collapsed; panel.classList.toggle('collapsed', state.collapsed); $('#ch-collapse', panel).textContent = state.collapsed ? '+' : '−'; }); $('#ch-close', panel).addEventListener('click', () => { panel.style.display = 'none'; }); $('#ch-video-toggle', panel).addEventListener('click', toggleVideoPlayback); $('#ch-prev', panel).addEventListener('click', () => navigateRow(-1)); $('#ch-next', panel).addEventListener('click', () => navigateRow(1)); $('#ch-speed', panel).addEventListener('change', event => setPlaybackSpeed(Number(event.target.value))); $('#ch-run-toggle', panel).addEventListener('click', toggleStudyTimer); $('#ch-timer-stop', panel).addEventListener('click', stopStudyTimer); $('#ch-refresh', panel).addEventListener('click', () => { refreshPageData(); renderCourseList(); setNotice('目录已刷新'); }); $('#ch-mark', panel).addEventListener('click', markCurrentReviewed); $('#ch-list-toggle', panel).addEventListener('click', toggleCourseList); $('#ch-seek', panel).addEventListener('input', onSeekInput); $('#ch-seek', panel).addEventListener('change', onSeekChange); makeDraggable(panel, $('#ch-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 - 48, startTop + event.clientY - startY)); panel.style.left = `${left}px`; panel.style.top = `${top}px`; panel.style.right = 'auto'; }); handle.addEventListener('pointerup', () => { dragging = false; }); handle.addEventListener('pointercancel', () => { dragging = false; }); } function toggleVideoPlayback() { const video = state.video || getVideoElement(); if (!video) { setNotice('当前页面未检测到视频'); return; } if (video.paused || video.ended) { video.play().then(() => setNotice('视频播放中')).catch(() => setNotice('播放未成功,请先点击页面播放器')); } else { video.pause(); setNotice('视频已暂停'); } renderVideoState(); } function setPlaybackSpeed(speed) { if (!Number.isFinite(speed) || speed <= 0) return; state.speed = speed; const video = state.video || getVideoElement(); if (video) video.playbackRate = speed; const select = $('#ch-speed'); if (select) select.value = String(speed); setNotice(`播放速度 ${speed}×`); } function navigateRow(offset) { if (!state.rows.length) { setNotice('尚未读取到课件目录'); return; } let index = state.rows.indexOf(state.activeRow); if (index < 0) index = offset > 0 ? -1 : state.rows.length; const next = Math.max(0, Math.min(state.rows.length - 1, index + offset)); const row = state.rows[next]; if (!row) return; row.scrollIntoView({ behavior: 'smooth', block: 'center' }); row.click(); setNotice(`已选择:${getRowName(row)}`); setTimeout(refreshPageData, 700); setTimeout(refreshPageData, 1600); } function toggleCourseList() { state.listOpen = !state.listOpen; $('#ch-list-panel')?.classList.toggle('open', state.listOpen); if (state.listOpen) renderCourseList(); } function renderCourseList() { const panel = $('#ch-list-panel'); if (!panel) return; if (!state.rows.length) { panel.innerHTML = '
暂未识别到课件目录
'; return; } panel.replaceChildren(); state.rows.forEach((row, index) => { const button = document.createElement('button'); button.type = 'button'; button.className = `ch-row${row === state.activeRow ? ' active' : ''}`; const name = document.createElement('span'); name.textContent = `${index + 1}. ${getRowName(row)}`; const type = document.createElement('small'); type.textContent = getRowType(row); button.append(name, type); button.addEventListener('click', () => { row.scrollIntoView({ behavior: 'smooth', block: 'center' }); row.click(); setNotice(`已选择:${getRowName(row)}`); setTimeout(refreshPageData, 700); setTimeout(refreshPageData, 1600); }); panel.appendChild(button); }); } function markCurrentReviewed() { if (!state.activeRow) { setNotice('尚未识别当前课件'); return; } const key = getRowKey(state.activeRow); state.record.reviewed[key] = { name: getRowName(state.activeRow), markedAt: Date.now(), method: 'manual' }; state.record.lastItem = getRowName(state.activeRow); saveRecord(); setNotice('本节已加入本地记录'); renderCourseList(); } function toggleStudyTimer() { if (state.studying) { pauseStudyTimer(); return; } state.studying = true; state.lastTick = Date.now(); if (state.timerId) clearInterval(state.timerId); state.timerId = setInterval(tickStudyTimer, 1000); renderTimerState(); setNotice('学习计时已运行'); } function pauseStudyTimer() { state.studying = false; state.lastTick = 0; if (state.timerId) clearInterval(state.timerId); state.timerId = null; saveRecord(); renderTimerState(); setNotice('学习计时已暂停'); } function stopStudyTimer() { pauseStudyTimer(); setNotice('计时已停止,累计时间已保存'); } function tickStudyTimer() { if (!state.studying) return; const now = Date.now(); const visible = document.visibilityState === 'visible'; if (visible && isCoursePage() && state.lastTick) { state.elapsedMs += Math.max(0, Math.min(now - state.lastTick, 2000)); } state.lastTick = now; if (Math.floor(now / 10000) !== Math.floor((now - 1000) / 10000)) saveRecord(); renderTimerState(); } function renderTimerState() { const button = $('#ch-run-toggle'); if (button) { button.textContent = state.studying ? '暂停计时' : '运行计时'; button.classList.toggle('running', state.studying); } const total = $('#ch-time-total'); if (total) total.textContent = `累计 ${formatDuration(state.elapsedMs / 1000)}`; } function attachVideoListeners(video) { if (video === state.video) return; if (state.video) { state.video.removeEventListener('timeupdate', onVideoUpdate); state.video.removeEventListener('durationchange', onVideoUpdate); state.video.removeEventListener('loadedmetadata', onVideoUpdate); state.video.removeEventListener('ended', onVideoEnded); state.video.removeEventListener('play', onVideoUpdate); state.video.removeEventListener('pause', onVideoUpdate); } state.video = video || null; if (state.video) { state.video.playbackRate = state.speed; state.video.addEventListener('timeupdate', onVideoUpdate); state.video.addEventListener('durationchange', onVideoUpdate); state.video.addEventListener('loadedmetadata', onVideoUpdate); state.video.addEventListener('ended', onVideoEnded); state.video.addEventListener('play', onVideoUpdate); state.video.addEventListener('pause', onVideoUpdate); } renderVideoState(); } function onVideoUpdate() { renderVideoState(); } function onVideoEnded() { if (!state.activeRow) return; const key = getRowKey(state.activeRow); state.record.reviewed[key] = { name: getRowName(state.activeRow), markedAt: Date.now(), method: 'video-ended' }; state.record.lastItem = getRowName(state.activeRow); saveRecord(); setNotice('视频自然播放结束,本地记录已更新'); renderCourseList(); } function onSeekInput(event) { const video = state.video; if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return; state.seeking = true; const percent = Number(event.target.value) / video.duration * 100; event.target.style.setProperty('--progress', `${percent}%`); $('#ch-current-time').textContent = formatClock(Number(event.target.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; renderVideoState(); } function renderVideoState() { const video = state.video; const currentEl = $('#ch-current-time'); const durationEl = $('#ch-duration'); const seek = $('#ch-seek'); const toggle = $('#ch-video-toggle'); if (!currentEl || !durationEl || !seek || !toggle) return; if (!video) { currentEl.textContent = '00:00'; durationEl.textContent = '00:00'; seek.max = '100'; seek.value = '0'; seek.style.setProperty('--progress', '0%'); toggle.textContent = '▶'; toggle.title = '播放'; return; } const duration = Number.isFinite(video.duration) ? video.duration : 0; const current = Number.isFinite(video.currentTime) ? video.currentTime : 0; currentEl.textContent = formatClock(current); durationEl.textContent = formatClock(duration); seek.max = String(duration || 100); if (!state.seeking) seek.value = String(current); const percent = duration > 0 ? Math.min(100, current / duration * 100) : 0; seek.style.setProperty('--progress', `${percent}%`); toggle.textContent = video.paused || video.ended ? '▶' : 'Ⅱ'; toggle.title = video.paused || video.ended ? '播放' : '暂停'; } function renderPanel() { const panel = $('#' + PANEL_ID); if (!panel) return; const active = state.activeRow; $('#ch-current-name', panel).textContent = active ? getRowName(active) : '等待识别当前课件'; $('#ch-current-type', panel).textContent = active ? `${getRowType(active)} · COURSE MATERIAL` : 'COURSE MATERIAL'; $('#ch-mark', panel).textContent = active && state.record.reviewed[getRowKey(active)] ? '已记录本节' : '记录本节'; $('#ch-mark', panel).disabled = !active; renderTimerState(); renderVideoState(); if (state.listOpen) renderCourseList(); } function refreshPageData() { state.rows = getRows(); state.activeRow = getActiveRow(); attachVideoListeners(getVideoElement()); if (state.activeRow) 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'] }); } function onRouteChange() { if (state.lastPath === location.pathname) return; state.lastPath = location.pathname; loadRecord(); queueRefresh(); if (!isCoursePage() && state.studying) pauseStudyTimer(); } function hookHistory() { const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = function (...args) { const result = originalPushState.apply(this, args); onRouteChange(); return result; }; history.replaceState = function (...args) { const result = originalReplaceState.apply(this, args); onRouteChange(); return result; }; window.addEventListener('popstate', onRouteChange); } function init() { loadRecord(); ensurePanel(); hookHistory(); refreshPageData(); observePage(); document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible' && state.studying) state.lastTick = Date.now(); }); setTimeout(refreshPageData, 1200); setTimeout(refreshPageData, 3500); console.info(`[重庆高教在线播放器] v${VERSION} 已加载`, { path: location.pathname, coursewareRows: state.rows.length }); } if (document.body) init(); else window.addEventListener('DOMContentLoaded', init, { once: true }); })();