// ==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 = `
`;
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();
})();