// ==UserScript== // @name TC视频助手 // @namespace chd-course-auto-next // @version 1.2.0 // @description 自动播放视频、自动下一节、倍速、静音、播放健康监控,并提供可拖拽悬浮状态面板 // @match https://course-online.chd.edu.cn/* // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; // ========================================== // 全局状态 // ========================================== let watchedVideo = null; let changing = false; let completedCount = 0; // ========================================== // 配置 // ========================================== const CONFIG_KEY = 'chd-auto-config'; const defaultConfig = { autoPlay: true, autoNext: true, autoRecover: true, autoMute: false, playbackRate: 1 }; let config = loadConfig(); let autoPlayEnabled = config.autoPlay; let autoNextEnabled = config.autoNext; let autoRecoverEnabled = config.autoRecover; let autoMuteEnabled = config.autoMute; let playbackRate = config.playbackRate; let currentTitle = '等待视频...'; let currentStatus = '初始化'; // ========================================== // 播放健康监控 // ========================================== let healthTimer = null; let lastVideoTime = 0; let lastHealthCheck = 0; let lastRecoveryTime = 0; let recoveryCount = 0; // 连续多少秒没有进度才认为可能卡顿 const STALL_THRESHOLD = 8; // 两次自动恢复之间至少间隔多少秒 const RECOVERY_COOLDOWN = 5; // 单个视频最多自动恢复次数 const MAX_RECOVERY_COUNT = 3; // ========================================== // 配置读取 // ========================================== function loadConfig() { try { const saved = localStorage.getItem(CONFIG_KEY); if (saved) { const parsed = JSON.parse(saved); return { ...defaultConfig, ...parsed }; } } catch (error) { console.warn( '[畅课助手] 配置读取失败', error ); } return { ...defaultConfig }; } // ========================================== // 配置保存 // ========================================== function saveConfig() { try { localStorage.setItem( CONFIG_KEY, JSON.stringify({ autoPlay: autoPlayEnabled, autoNext: autoNextEnabled, autoRecover: autoRecoverEnabled, autoMute: autoMuteEnabled, playbackRate: playbackRate }) ); } catch (error) { console.warn( '[畅课助手] 配置保存失败', error ); } } // ========================================== // 创建状态面板 // ========================================== function createPanel() { if ( document.getElementById( 'chd-auto-panel' ) ) { return; } const panel = document.createElement('div'); panel.id = 'chd-auto-panel'; panel.innerHTML = `
🎓 畅课助手
状态 初始化
视频 等待...
播放 等待
健康 正常
进度 00:00 / 00:00
已完成 0 节
播放控制
倍速
等待脚本启动...
`; document.body.appendChild(panel); // ========================================== // CSS // ========================================== const style = document.createElement('style'); style.textContent = ` #chd-auto-panel { position: fixed; top: 80px; right: 20px; width: 280px; z-index: 999999999; background: rgba(25, 25, 28, 0.96); color: #ffffff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.35); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; font-size: 13px; overflow: hidden; user-select: none; touch-action: none; } #chd-panel-header { height: 42px; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; background: rgba(55,55,60,0.95); font-size: 14px; font-weight: 600; cursor: grab; touch-action: none; } #chd-panel-header.chd-dragging { cursor: grabbing; } #chd-panel-header span { pointer-events: none; } #chd-collapse { width: 26px; height: 26px; border: none; border-radius: 6px; background: rgba(255,255,255,0.12); color: white; cursor: pointer; font-size: 18px; line-height: 20px; touch-action: manipulation; } #chd-collapse:hover { background: rgba(255,255,255,0.22); } #chd-panel-body { padding: 12px; } .chd-status-row { display: flex; justify-content: space-between; margin-bottom: 9px; line-height: 18px; } .chd-status-row span:first-child { color: #aaaaaa; } .chd-status-row span:last-child { max-width: 175px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #ffffff; } .chd-divider { height: 1px; background: rgba(255,255,255,0.10); margin: 10px 0; } .chd-control-title { color: #aaaaaa; font-size: 12px; margin: 2px 0 8px; } .chd-speed-row { display: flex; align-items: center; justify-content: space-between; margin: 9px 0; color: #dddddd; } .chd-speed-buttons { display: flex; gap: 5px; } .chd-speed-btn { border: none; border-radius: 5px; padding: 4px 7px; background: rgba(255,255,255,0.10); color: #dddddd; cursor: pointer; font-size: 11px; } .chd-speed-btn:hover { background: rgba(255,255,255,0.18); } .chd-speed-btn.active { background: rgba(99,230,190,0.22); color: #63e6be; } .chd-switch-row { display: flex; align-items: center; justify-content: space-between; margin: 9px 0; color: #dddddd; cursor: pointer; } .chd-switch-row input { width: 16px; height: 16px; cursor: pointer; } #chd-log { min-height: 34px; color: #8fc7ff; font-size: 12px; line-height: 17px; word-break: break-all; } #chd-status { color: #63e6be; } #chd-health-status { color: #63e6be; } `; document.head.appendChild(style); // ========================================== // 折叠 // ========================================== document .getElementById('chd-collapse') .addEventListener( 'click', function () { const body = document.getElementById( 'chd-panel-body' ); if ( body.style.display === 'none' ) { body.style.display = 'block'; this.textContent = '−'; } else { body.style.display = 'none'; this.textContent = '+'; } } ); // ========================================== // 设置开关 // ========================================== const autoPlayCheckbox = document.getElementById( 'chd-auto-play' ); const autoNextCheckbox = document.getElementById( 'chd-auto-next' ); const autoRecoverCheckbox = document.getElementById( 'chd-auto-recover' ); const autoMuteCheckbox = document.getElementById( 'chd-auto-mute' ); autoPlayCheckbox.checked = autoPlayEnabled; autoNextCheckbox.checked = autoNextEnabled; autoRecoverCheckbox.checked = autoRecoverEnabled; autoMuteCheckbox.checked = autoMuteEnabled; autoPlayCheckbox.addEventListener( 'change', function () { autoPlayEnabled = this.checked; saveConfig(); addLog( autoPlayEnabled ? '自动播放:开启' : '自动播放:关闭' ); } ); autoNextCheckbox.addEventListener( 'change', function () { autoNextEnabled = this.checked; saveConfig(); addLog( autoNextEnabled ? '自动下一节:开启' : '自动下一节:关闭' ); } ); autoRecoverCheckbox.addEventListener( 'change', function () { autoRecoverEnabled = this.checked; saveConfig(); addLog( autoRecoverEnabled ? '自动恢复:开启' : '自动恢复:关闭' ); } ); autoMuteCheckbox.addEventListener( 'change', function () { autoMuteEnabled = this.checked; saveConfig(); const video = getVideo(); if ( video && autoMuteEnabled ) { video.muted = true; } addLog( autoMuteEnabled ? '自动静音:开启' : '自动静音:关闭' ); } ); // ========================================== // 倍速按钮 // ========================================== document .querySelectorAll( '.chd-speed-btn' ) .forEach(function (button) { button.addEventListener( 'click', function () { const speed = Number( this.dataset.speed ); setPlaybackRate(speed); } ); }); updateSpeedButtons(); // ========================================== // 启用拖拽 // ========================================== enablePanelDrag(panel); } // ========================================== // 浮窗拖拽 // ========================================== function enablePanelDrag(panel) { const header = document.getElementById( 'chd-panel-header' ); if (!header) { return; } const storageKey = 'chd-auto-panel-position'; // 恢复位置 try { const saved = localStorage.getItem( storageKey ); if (saved) { const position = JSON.parse(saved); if ( typeof position.left === 'number' && typeof position.top === 'number' ) { panel.style.left = position.left + 'px'; panel.style.top = position.top + 'px'; panel.style.right = 'auto'; keepPanelInsideWindow( panel ); } } } catch (error) { console.warn( '[畅课助手] 无法读取浮窗位置', error ); } let dragging = false; let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; header.addEventListener( 'pointerdown', function (event) { if ( event.target.closest && event.target.closest( '#chd-collapse' ) ) { return; } if ( event.pointerType === 'mouse' && event.button !== 0 ) { return; } const rect = panel.getBoundingClientRect(); panel.style.left = rect.left + 'px'; panel.style.top = rect.top + 'px'; panel.style.right = 'auto'; startX = event.clientX; startY = event.clientY; startLeft = rect.left; startTop = rect.top; dragging = true; header.classList.add( 'chd-dragging' ); try { header.setPointerCapture( event.pointerId ); } catch (error) {} event.preventDefault(); } ); header.addEventListener( 'pointermove', function (event) { if (!dragging) { return; } const deltaX = event.clientX - startX; const deltaY = event.clientY - startY; let newLeft = startLeft + deltaX; let newTop = startTop + deltaY; const maxLeft = window.innerWidth - panel.offsetWidth; const maxTop = window.innerHeight - panel.offsetHeight; newLeft = Math.max( 0, Math.min( newLeft, Math.max( 0, maxLeft ) ) ); newTop = Math.max( 0, Math.min( newTop, Math.max( 0, maxTop ) ) ); panel.style.left = newLeft + 'px'; panel.style.top = newTop + 'px'; panel.style.right = 'auto'; event.preventDefault(); } ); function stopDragging(event) { if (!dragging) { return; } dragging = false; header.classList.remove( 'chd-dragging' ); try { if ( event && header.hasPointerCapture && header.hasPointerCapture( event.pointerId ) ) { header.releasePointerCapture( event.pointerId ); } } catch (error) {} savePanelPosition(panel); } header.addEventListener( 'pointerup', stopDragging ); header.addEventListener( 'pointercancel', stopDragging ); window.addEventListener( 'resize', function () { keepPanelInsideWindow( panel ); savePanelPosition( panel ); } ); } // ========================================== // 限制浮窗不能跑出窗口 // ========================================== function keepPanelInsideWindow(panel) { const rect = panel.getBoundingClientRect(); let left = rect.left; let top = rect.top; const maxLeft = window.innerWidth - panel.offsetWidth; const maxTop = window.innerHeight - panel.offsetHeight; left = Math.max( 0, Math.min( left, Math.max( 0, maxLeft ) ) ); top = Math.max( 0, Math.min( top, Math.max( 0, maxTop ) ) ); panel.style.left = left + 'px'; panel.style.top = top + 'px'; panel.style.right = 'auto'; } // ========================================== // 保存浮窗位置 // ========================================== function savePanelPosition(panel) { try { const rect = panel.getBoundingClientRect(); localStorage.setItem( 'chd-auto-panel-position', JSON.stringify({ left: rect.left, top: rect.top }) ); } catch (error) { console.warn( '[畅课助手] 无法保存浮窗位置', error ); } } // ========================================== // 更新状态 // ========================================== function setStatus(text) { currentStatus = text; const element = document.getElementById( 'chd-status' ); if (element) { element.textContent = text; } } // ========================================== // 日志 // ========================================== function addLog(text) { console.log( '[畅课助手]', text ); const element = document.getElementById( 'chd-log' ); if (element) { element.textContent = text; } } // ========================================== // 查找视频 // ========================================== function getVideo() { return document.querySelector( 'video' ); } // ========================================== // 查找下一节 // ========================================== function getNextButton() { const list = document.querySelectorAll( 'button, a' ); for ( const element of list ) { if ( element.textContent.trim() === '下一个' ) { return element; } } return null; } // ========================================== // 获取课程名称 // ========================================== function getTitle() { const title = document.querySelector( '.activity-title' ); if (title) { return title.textContent.trim(); } return '当前视频'; } // ========================================== // 播放倍速 // ========================================== function setPlaybackRate( rate, silent = false ) { const allowedRates = [1, 1.5, 2]; if ( !allowedRates.includes(rate) ) { rate = 1; } playbackRate = rate; const video = getVideo(); if (video) { try { video.playbackRate = rate; } catch (error) { console.warn( '[畅课助手] 设置倍速失败', error ); } } updateSpeedButtons(); saveConfig(); if (!silent) { addLog( '播放倍速:' + rate + 'x' ); } } function updateSpeedButtons() { document .querySelectorAll( '.chd-speed-btn' ) .forEach(function (button) { const speed = Number( button.dataset.speed ); button.classList.toggle( 'active', speed === playbackRate ); }); } // ========================================== // 应用视频设置 // ========================================== function applyVideoSettings(video) { if (!video) { return; } try { video.playbackRate = playbackRate; } catch (error) { console.warn( '[畅课助手] 设置倍速失败', error ); } if (autoMuteEnabled) { video.muted = true; } updateSpeedButtons(); } // ========================================== // 自动播放 // ========================================== function autoPlay(video) { if (!autoPlayEnabled) { return; } applyVideoSettings(video); if (!video.paused) { setStatus( '播放中' ); return; } setStatus( '尝试播放' ); addLog( '正在尝试自动播放...' ); const promise = video.play(); if ( promise !== undefined ) { promise .then(function () { setStatus( '播放中' ); addLog( '✓ 自动播放成功' ); }) .catch(function (error) { console.warn( error ); setStatus( '需要播放' ); addLog( '浏览器阻止自动播放' ); clickPlayButton(); }); } } // ========================================== // Video.js 播放按钮 // ========================================== function clickPlayButton() { const selectors = [ '.vjs-big-play-button', '.vjs-play-control', 'button.vjs-big-play-button', 'button.vjs-play-control' ]; for ( const selector of selectors ) { const button = document.querySelector( selector ); if (button) { addLog( '找到播放器按钮,尝试点击' ); button.click(); return; } } addLog( '未找到播放器播放按钮' ); } // ========================================== // 播放健康状态 // ========================================== function updateHealthStatus( text, healthy = true ) { const element = document.getElementById( 'chd-health-status' ); if (!element) { return; } element.textContent = text; element.style.color = healthy ? '#63e6be' : '#ffd166'; } // ========================================== // 重置健康监控 // ========================================== function resetHealthMonitor(video) { lastVideoTime = video ? video.currentTime || 0 : 0; lastHealthCheck = Date.now(); lastRecoveryTime = 0; recoveryCount = 0; updateHealthStatus( '正常', true ); } // ========================================== // 监控视频健康状态 // ========================================== function monitorVideoHealth(video) { if (!video) { updateHealthStatus( '等待视频', true ); return; } const now = Date.now(); const currentTime = video.currentTime || 0; // 视频结束 if (video.ended) { updateHealthStatus( '已完成', true ); lastVideoTime = currentTime; lastHealthCheck = now; return; } // 用户主动暂停 if (video.paused) { updateHealthStatus( '已暂停', true ); lastVideoTime = currentTime; lastHealthCheck = now; return; } // 正在拖动进度条 if (video.seeking) { updateHealthStatus( '跳转中', true ); lastVideoTime = currentTime; lastHealthCheck = now; return; } const elapsed = ( now - lastHealthCheck ) / 1000; const progress = Math.abs( currentTime - lastVideoTime ); // 正常播放 if ( elapsed < 1 || progress > 0.15 ) { updateHealthStatus( '正常', true ); lastVideoTime = currentTime; lastHealthCheck = now; recoveryCount = 0; return; } // 超过阈值没有进度 if ( elapsed >= STALL_THRESHOLD ) { updateHealthStatus( '检测到卡顿', false ); if (!autoRecoverEnabled) { addLog( '⚠ 检测到视频长时间无进度' ); lastHealthCheck = now; return; } if ( now - lastRecoveryTime < RECOVERY_COOLDOWN * 1000 ) { return; } if ( recoveryCount >= MAX_RECOVERY_COUNT ) { updateHealthStatus( '恢复失败', false ); setStatus( '播放异常' ); addLog( '⚠ 自动恢复已达到最大重试次数' ); lastHealthCheck = now; return; } recoverVideo(video); } } // ========================================== // 自动恢复播放 // ========================================== function recoverVideo(video) { recoveryCount++; lastRecoveryTime = Date.now(); setStatus( '恢复播放' ); updateHealthStatus( '正在恢复', false ); addLog( '↻ 检测到播放卡顿,尝试恢复(' + recoveryCount + '/' + MAX_RECOVERY_COUNT + ')' ); try { // 恢复倍速 // 恢复静音 applyVideoSettings( video ); const promise = video.play(); if ( promise && typeof promise.catch === 'function' ) { promise .then(function () { updateHealthStatus( '恢复成功', true ); addLog( '✓ 播放恢复成功' ); }) .catch(function (error) { console.warn( '[畅课助手] 自动恢复播放失败', error ); addLog( '⚠ 自动恢复失败,尝试点击播放器' ); clickPlayButton(); }); } else { updateHealthStatus( '恢复尝试中', true ); } } catch (error) { console.warn( '[畅课助手] 播放恢复异常', error ); addLog( '⚠ 播放恢复异常' ); clickPlayButton(); } lastVideoTime = video.currentTime || 0; lastHealthCheck = Date.now(); } // ========================================== // 启动健康监控 // ========================================== function startHealthMonitor() { if (healthTimer) { return; } healthTimer = setInterval( function () { const video = getVideo(); if (!video) { updateHealthStatus( '等待视频', true ); return; } monitorVideoHealth( video ); }, 2000 ); } // ========================================== // 视频结束 // ========================================== function videoEnded() { completedCount++; updateCompleted(); setStatus( '视频完成' ); updateHealthStatus( '已完成', true ); addLog( '✓ 视频播放完成' ); if (!autoNextEnabled) { addLog( '自动下一节已关闭' ); return; } if (changing) { return; } const next = getNextButton(); if (!next) { setStatus( '等待下一节' ); addLog( '未找到“下一个”按钮' ); return; } changing = true; setStatus( '切换中' ); addLog( '准备进入下一节...' ); setTimeout( function () { next.click(); addLog( '→ 已点击“下一个”' ); setTimeout( function () { changing = false; checkVideo(); }, 2000 ); }, 1000 ); } // ========================================== // 更新完成数量 // ========================================== function updateCompleted() { const element = document.getElementById( 'chd-completed' ); if (element) { element.textContent = completedCount + ' 节'; } } // ========================================== // 更新播放进度 // ========================================== function updateProgress(video) { if (!video) { return; } const current = video.currentTime || 0; const duration = video.duration || 0; function format(time) { if ( !isFinite(time) ) { return '00:00'; } const minutes = Math.floor( time / 60 ); const seconds = Math.floor( time % 60 ); return String( minutes ).padStart(2, '0') + ':' + String( seconds ).padStart(2, '0'); } const element = document.getElementById( 'chd-progress' ); if (element) { element.textContent = format(current) + ' / ' + format(duration); } const playStatus = document.getElementById( 'chd-play-status' ); if (playStatus) { if (video.ended) { playStatus.textContent = '已完成'; } else if (video.paused) { playStatus.textContent = '已暂停'; } else { playStatus.textContent = '播放中'; } } } // ========================================== // 检查视频 // ========================================== function checkVideo() { const video = getVideo(); if (!video) { setStatus( '等待视频' ); updateHealthStatus( '等待视频', true ); return; } // 当前还是同一个 video if ( video === watchedVideo ) { applyVideoSettings( video ); updateProgress( video ); return; } // 检测到新 video watchedVideo = video; applyVideoSettings( video ); resetHealthMonitor( video ); currentTitle = getTitle(); const titleElement = document.getElementById( 'chd-video-title' ); if (titleElement) { titleElement.textContent = currentTitle; } setStatus( '检测到新视频' ); addLog( '✓ 检测到新视频' ); // 视频结束 video.addEventListener( 'ended', videoEnded ); // 开始播放 video.addEventListener( 'play', function () { setStatus( '播放中' ); updateHealthStatus( '正常', true ); addLog( '▶ 视频开始播放' ); } ); // 暂停 video.addEventListener( 'pause', function () { if (!video.ended) { setStatus( '已暂停' ); updateHealthStatus( '已暂停', true ); addLog( '⏸ 视频暂停' ); } } ); // 自动静音监控 video.addEventListener( 'volumechange', function () { if ( autoMuteEnabled && !video.muted ) { video.muted = true; } } ); // 延迟应用一次设置 setTimeout( function () { applyVideoSettings( video ); autoPlay( video ); }, 500 ); } // ========================================== // 创建面板 // ========================================== createPanel(); // ========================================== // 启动播放健康监控 // ========================================== startHealthMonitor(); // ========================================== // 定时检查视频 // ========================================== setInterval( function () { if (!changing) { checkVideo(); } }, 1000 ); // ========================================== // 初始化 // ========================================== checkVideo(); })();