// ==UserScript== // @name AES云上授课助手 // @namespace https://scriptcat.org/ // @version 1.0.151 // @description AES云上授课助手1.0.151 - 针对出头科技云上教学系统的自动刷课脚本(功能图标已替换为 HarmonyOS 线性风格并修复居中;LOGO 未改动) // @Tag 网课自动化 // @author 绫白 // @match https://csjs.web2.superchutou.com/* // @match https://*.superchutou.com/* // @grant GM_notification // @grant GM_log // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_addStyle // @run-at document-end // @license MIT // ==/UserScript== (function() { 'use strict'; // ==================== 配置项 ==================== const CONFIG = { playbackRate: 1.5, autoMute: true, enableBackgroundPlay: true, checkInterval: 2000, startWaitTime: 2000, nextVideoDelay: 2000, showNotification: true, debug: true }; // ==================== 工具函数 ==================== // [③] 日志分级:warn/error 恒输出;info/success 受 CONFIG.debug 控制 function log(message, type = 'info') { const prefix = '[AES助手]'; const timestamp = new Date().toLocaleTimeString(); const fullMessage = `${prefix} [${timestamp}] ${message}`; const critical = (type === 'warn' || type === 'error'); if (!CONFIG.debug && !critical) return; const styles = { info: 'color: #2196F3', success: 'color: #4CAF50', warn: 'color: #FF9800', error: 'color: #F44336' }; console.log(`%c${fullMessage}`, styles[type] || styles.info); GM_log(fullMessage, type); } function notify(title, text, timeout = 5000) { if (CONFIG.showNotification) GM_notification({ title, text, timeout }); log(`${title}: ${text}`, 'success'); } // ==================== 倍速防护锁 - 模块级状态 ==================== let _protoLocked = false; let _nativePlaybackRateDesc = null; // ==================== 独立矢量LOGO (防ID冲突) ==================== // 【严格保留,未做任何改动】 const LOGO_SVG = ``; // ==================== 功能图标集 (HarmonyOS 线性风格) ==================== // 双态约定:.icon-pair 中第一个 = 默认态(无 .alt),第二个 = .alt 态 // 如需替换为 HarmonyOS_Icons.zip 内官方 SVG:解开对应 key, // 粘贴官方 节点,并把其中的 black 统一改为 currentColor 即可。 const ICONS = { play: ``, pause: ``, prev: ``, next: ``, volume: ``, mute: ``, plus: ``, minus: `` }; // 统一 SVG 外壳:currentColor 继承按钮文字色,hover/active 变色零成本 const iconSvg = (name) => `${ICONS[name]}`; // ==================== 全局CSS提取 (解决CSP拦截) ==================== // [⑦] 严格作用域隔离:所有选择器强制带 #aes-panel 前缀;@keyframes 改名 aes-pulse const PANEL_CSS = ` #aes-panel { position: fixed; top: 80px; right: 20px; width: 320px; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); border-radius: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); z-index: 999999; font-family: '汉仪文黑85W', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #fff; overflow: hidden; transition: all 0.3s ease; user-select: none; } #aes-panel.collapsed { width: 50px; height: 50px; border-radius: 50%; cursor: pointer; } #aes-panel.collapsed .panel-content { display: none; } #aes-panel.collapsed .panel-header { padding: 0; justify-content: center; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } #aes-panel.collapsed .panel-title { display: none; } #aes-panel.collapsed .toggle-btn { display: none; } #aes-panel.collapsed .collapsed-icon { display: flex !important; } #aes-panel .aes-logo { vertical-align: middle; margin-right: 6px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2)); } #aes-panel.collapsed .aes-logo { width: 26px; height: 26px; margin: 0; } #aes-panel .panel-header { background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; cursor: move; } #aes-panel .panel-title { font-size: 14px; font-weight: 600; display: flex; align-items: center; } #aes-panel .collapsed-icon { display: none; align-items: center; justify-content: center; width: 100%; height: 100%; } #aes-panel .toggle-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; width: 24px; height: 24px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s; font-size: 16px; line-height: 1; } #aes-panel .toggle-btn:hover { background: rgba(255,255,255,0.3); } #aes-panel .panel-content { padding: 16px; } #aes-panel .status-card { background: rgba(255,255,255,0.08); border-radius: 12px; padding: 12px; margin-bottom: 12px; } #aes-panel .status-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } #aes-panel .status-label { font-size: 12px; color: #888; } #aes-panel .status-value { font-size: 13px; font-weight: 500; display: flex; align-items: center; gap: 4px; } #aes-panel .status-running { color: #4CAF50; } #aes-panel .status-stopped { color: #ff9800; } #aes-panel .status-dot { width: 8px; height: 8px; border-radius: 50%; background: #4CAF50; animation: aes-pulse 1.5s infinite; } #aes-panel .status-dot.stopped { background: #ff9800; animation: none; } @keyframes aes-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } #aes-panel .progress-bar { height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden; margin: 8px 0; } #aes-panel .progress-fill { height: 100%; background: linear-gradient(90deg, #4CAF50, #8BC34A); border-radius: 3px; transition: width 0.3s ease; } #aes-panel .progress-text { display: flex; justify-content: space-between; font-size: 11px; color: #888; } #aes-panel .video-info { background: rgba(255,255,255,0.05); border-radius: 8px; padding: 10px; margin-top: 8px; } #aes-panel .video-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #aes-panel .video-index { font-size: 11px; color: #888; } #aes-panel .control-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 12px; } #aes-panel .ctrl-btn { background: rgba(255,255,255,0.1); border: none; color: #fff; padding: 10px 6px; border-radius: 10px; cursor: pointer; transition: all 0.2s; font-size: 16px; } #aes-panel .ctrl-btn:hover { background: rgba(255,255,255,0.2); transform: translateY(-1px); } #aes-panel .ctrl-btn.active { background: linear-gradient(135deg, #4CAF50, #8BC34A); } #aes-panel .ctrl-btn:disabled { opacity: 0.5; cursor: not-allowed; } #aes-panel .speed-section { margin-bottom: 12px; } #aes-panel .speed-header { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 12px; } #aes-panel .speed-current { color: #667eea; font-weight: 600; } #aes-panel .speed-buttons { display: flex; gap: 6px; } #aes-panel .speed-btn { flex: 1; background: rgba(255,255,255,0.1); border: none; color: #fff; padding: 8px 4px; border-radius: 8px; cursor: pointer; font-size: 12px; transition: all 0.2s; } #aes-panel .speed-btn:hover { background: rgba(255,255,255,0.2); } #aes-panel .speed-btn.active { background: linear-gradient(135deg, #667eea, #764ba2); } #aes-panel .stats-row { display: flex; justify-content: space-around; padding: 12px 0; border-top: 1px solid rgba(255,255,255,0.1); } #aes-panel .stat-item { text-align: center; } #aes-panel .stat-value { font-size: 18px; font-weight: 600; color: #fff; } #aes-panel .stat-label { font-size: 10px; color: #888; margin-top: 2px; } #aes-panel .stat-value.highlight { color: #4CAF50; } #aes-panel .footer-credit { text-align: center; padding: 10px 0 6px; border-top: 1px solid rgba(255,255,255,0.08); margin-top: 8px; } #aes-panel .footer-credit-text { font-size: 10px; color: #555; line-height: 1.5; } #aes-panel .footer-credit-text span { color: #4facfe; font-weight: bold; } @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) { #aes-panel { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } #aes-panel .stats-row, #aes-panel .footer-credit { border-top: none; position: relative; } #aes-panel .stats-row::after, #aes-panel .footer-credit::after { content: ''; position: absolute; left: 0; top: 0; width: 200%; height: 200%; transform-origin: 0 0; transform: scale(0.5); pointer-events: none; box-sizing: border-box; } #aes-panel .stats-row::after { border-top: 1px solid rgba(255,255,255,0.1); } #aes-panel .footer-credit::after { border-top: 1px solid rgba(255,255,255,0.08); } } #aes-panel .panel-header, #aes-panel.collapsed { touch-action: none; } /* ===== HarmonyOS 图标系统(替换原字符图标)===== */ #aes-panel .aes-icon { display: block; pointer-events: none; } /* 关键修复:按钮改为 flex 居中,兼容单图标与双态图标两种结构 */ #aes-panel .ctrl-btn { font-size: 0; line-height: 0; /* 消除行内基线带来的垂直偏移 */ display: flex; align-items: center; justify-content: center; } #aes-panel .ctrl-btn svg { width: 18px; height: 18px; flex: none; } #aes-panel .toggle-btn { font-size: 0; line-height: 0; } #aes-panel .toggle-btn svg { width: 14px; height: 14px; flex: none; } /* 双态图标:默认显示第一个,加 .alt 切到第二个 */ #aes-panel .icon-pair { display: flex; /* 由 inline-flex 改为 flex,交给按钮的 flex 居中 */ align-items: center; justify-content: center; } #aes-panel .icon-pair > svg:last-child { display: none; } #aes-panel .alt > .icon-pair > svg:first-child { display: none; } #aes-panel .alt > .icon-pair > svg:last-child { display: block; } `; // ==================== UI面板类 ==================== class ControlPanel { constructor(manager) { this.manager = manager; this.panel = null; this.isCollapsed = false; this.isDragging = false; this.dragOffset = { x: 0, y: 0 }; this.updateTimer = null; this._lastToggleTime = 0; this._clampToken = 0; this._onPointerMove = null; // [②] document 级监听句柄 this._onPointerEnd = null; // [②] this.init(); } init() { GM_addStyle(PANEL_CSS); this.createPanel(); this.bindEvents(); this.startUpdate(); log('控制面板已创建(安全模式)', 'success'); } createPanel() { // 【HTML 结构保留,含 LOGO 引用;功能按钮改为 HarmonyOS 图标】 const container = document.createElement('div'); container.id = 'aes-panel-container'; container.innerHTML = `
${LOGO_SVG}AES云上授课助手 ${LOGO_SVG}
运行状态 运行中
00:00 0% 00:00
加载中...
第 0/0 个视频
播放速度 1.5x
0
已完成
0
剩余
0%
总进度
`; document.body.appendChild(container); this.panel = document.getElementById('aes-panel'); } bindEvents() { // [R6] 面板内元素一律走面板作用域查询,杜绝宿主同名 ID 误绑 this.panel.querySelector('#toggle-btn').addEventListener('click', (e) => { e.stopPropagation(); this.toggleCollapse(); }); // [P5] dblclick 加防抖 this.panel.querySelector('.panel-header').addEventListener('dblclick', () => { if (Date.now() - this._lastToggleTime < 300) return; this.toggleCollapse(); }); // —— 拖拽修复:悬浮球可拖 + 跨iframe/出窗不丢事件 + 视口钳制 —— this.panel.addEventListener('pointerdown', (e) => { if (!this.panel) return; if (!this.isCollapsed && !e.target.closest('.panel-header')) return; if (e.pointerType === 'mouse' && e.button !== 0) return; this._dragPending = true; this._dragPointerId = e.pointerId; this._dragStartX = e.clientX; this._dragStartY = e.clientY; const rect = this.panel.getBoundingClientRect(); this.dragOffset.x = e.clientX - rect.left; this.dragOffset.y = e.clientY - rect.top; }); // [②] 句柄化,便于 destroy 时移除 this._onPointerMove = (e) => { if (!this.panel) return; if (this._dragPending && !this.isDragging) { if (e.pointerId !== this._dragPointerId) return; if (Math.hypot(e.clientX - this._dragStartX, e.clientY - this._dragStartY) < 3) return; this.isDragging = true; this._dragPending = false; this.panel.style.transition = 'none'; try { this.panel.setPointerCapture(e.pointerId); } catch (err) {} } if (!this.isDragging) return; const maxX = Math.max(0, window.innerWidth - this.panel.offsetWidth); const maxY = Math.max(0, window.innerHeight - this.panel.offsetHeight); this.panel.style.left = Math.min(Math.max(0, e.clientX - this.dragOffset.x), maxX) + 'px'; this.panel.style.top = Math.min(Math.max(0, e.clientY - this.dragOffset.y), maxY) + 'px'; this.panel.style.right = 'auto'; }; this._onPointerEnd = () => { // [P5] 在清空 _dragPending 之前先判定"这是一次点击" const wasClick = this._dragPending && !this.isDragging; this._dragPending = false; if (this.isDragging) { this.isDragging = false; if (this.panel) this.panel.style.transition = ''; } // [P5] 收起态悬浮球:单击展开 if (wasClick && this.isCollapsed && this.panel) { this.toggleCollapse(); } }; document.addEventListener('pointermove', this._onPointerMove); document.addEventListener('pointerup', this._onPointerEnd); document.addEventListener('pointercancel', this._onPointerEnd); // [R6] 控制按钮事件绑定收窄至面板作用域 this.panel.querySelector('#btn-play').addEventListener('click', () => this.manager.togglePlay()); this.panel.querySelector('#btn-prev').addEventListener('click', () => this.manager.playPrevVideo()); this.panel.querySelector('#btn-next').addEventListener('click', () => this.manager.playNextVideo()); this.panel.querySelector('#btn-mute').addEventListener('click', () => this.manager.toggleMute()); // [⑦] 收窄到面板内查询,不再误绑宿主同名的 .speed-btn this.panel.querySelectorAll('.speed-btn').forEach(btn => { btn.addEventListener('click', () => { const speed = parseFloat(btn.dataset.speed); this.manager.setSpeed(speed); this.updateSpeedButtons(speed); }); }); } // [P4] 折叠/展开后,把面板钳制回视口内 clampToViewport() { if (!this.panel) return; const token = ++this._clampToken; setTimeout(() => { if (token !== this._clampToken) return; if (!this.panel) return; const rect = this.panel.getBoundingClientRect(); const maxX = Math.max(0, window.innerWidth - rect.width); const maxY = Math.max(0, window.innerHeight - rect.height); const left = parseFloat(this.panel.style.left); const top = parseFloat(this.panel.style.top); if (!isNaN(left)) { const nx = Math.min(Math.max(0, left), maxX); if (nx !== left) this.panel.style.left = nx + 'px'; } if (!isNaN(top)) { const ny = Math.min(Math.max(0, top), maxY); if (ny !== top) this.panel.style.top = ny + 'px'; } }, 320); } toggleCollapse() { this.isCollapsed = !this.isCollapsed; this._lastToggleTime = Date.now(); this.panel.classList.toggle('collapsed', this.isCollapsed); // [R6][图标] 作用域查询 + class 切换(收起显示 +,展开显示 −) this.panel.querySelector('#toggle-btn').classList.toggle('alt', this.isCollapsed); this.clampToViewport(); } updateSpeedButtons(speed) { // [⑦][R6] 收窄到面板内查询 this.panel.querySelectorAll('.speed-btn').forEach(btn => btn.classList.toggle('active', parseFloat(btn.dataset.speed) === speed)); this.panel.querySelector('#speed-display').textContent = speed + 'x'; } startUpdate() { this.updateTimer = setInterval(() => this.updateDisplay(), 500); } // [①][P6][P7][P12] 面板不再因 video=null 整块冻结;索引语义统一;显示"已停止";读缓存标题 // [R6] 全部元素查询收窄至面板作用域;[R7] 未连接时清空残留进度读数 // [图标] 播放/静音图标由 textContent 改为 .alt class 切换(预渲染双态,无 DOM 重建抖动) updateDisplay() { const video = this.manager.video; const total = this.manager.totalVideos; const rawIndex = this.manager.currentVideoIndex; const hasIndex = rawIndex >= 0 && rawIndex < total; const displayIndex = hasIndex ? rawIndex + 1 : 0; const completedCount = hasIndex ? rawIndex : 0; const titleEl = this.panel.querySelector('#video-title'); // [R6] if (hasIndex && this.manager._currentVideoTitle) { titleEl.textContent = this.manager._currentVideoTitle; } else if (!video) { titleEl.textContent = '未连接播放器'; } else { titleEl.textContent = '加载中...'; } this.panel.querySelector('#video-index').textContent = `第 ${displayIndex}/${total} 个视频`; // [R6] this.panel.querySelector('#stat-completed').textContent = completedCount; // [R6] this.panel.querySelector('#stat-remaining').textContent = Math.max(0, total - completedCount); // [R6] this.panel.querySelector('#stat-percent').textContent = total > 0 ? ((completedCount / total) * 100).toFixed(0) + '%' : '0%'; // [R6] if (!video) { this.panel.querySelector('#status-dot').classList.add('stopped'); // [R6] this.panel.querySelector('#status-label').textContent = '未连接播放器'; // [R6] // [图标] 无播放器:显示 ▶(.icon-pair 第一态),去掉激活态 this.panel.querySelector('#btn-play').classList.remove('alt'); // [R6] this.panel.querySelector('#btn-play').classList.remove('active'); // [R6] // [R7] 清空上一课残留的进度条/时间/百分比,保证监控数据准确 this.panel.querySelector('#progress-fill').style.width = '0%'; this.panel.querySelector('#current-time').textContent = '00:00'; this.panel.querySelector('#total-time').textContent = '00:00'; this.panel.querySelector('#progress-percent').textContent = '0%'; return; } const currentTime = video.currentTime || 0; const duration = video.duration || 0; const progress = duration > 0 ? (currentTime / duration * 100) : 0; const isPaused = video.paused; const statusDot = this.panel.querySelector('#status-dot'); // [R6] const statusLabel = this.panel.querySelector('#status-label'); // [R6] const playBtn = this.panel.querySelector('#btn-play'); // [R6] if (this.manager.userStopped) { statusDot.classList.add('stopped'); statusLabel.textContent = '已停止'; playBtn.classList.remove('alt'); // [图标] ▶ playBtn.classList.remove('active'); } else if (isPaused) { statusDot.classList.add('stopped'); statusLabel.textContent = '已暂停'; playBtn.classList.remove('alt'); // [图标] ▶ playBtn.classList.remove('active'); } else { statusDot.classList.remove('stopped'); statusLabel.textContent = '运行中'; playBtn.classList.add('alt'); // [图标] ⏸ playBtn.classList.add('active'); } this.panel.querySelector('#progress-fill').style.width = progress + '%'; // [R6] this.panel.querySelector('#current-time').textContent = this.manager.formatTime(currentTime); // [R6] this.panel.querySelector('#total-time').textContent = this.manager.formatTime(duration); // [R6] this.panel.querySelector('#progress-percent').textContent = progress.toFixed(0) + '%'; // [R6] const muteBtn = this.panel.querySelector('#btn-mute'); // [R6] muteBtn.classList.toggle('alt', video.muted); // [图标] 静音↔有声 muteBtn.classList.toggle('active', video.muted); } // [②] 补齐:清 timer + 移除 document 级拖拽监听 + 移除容器 destroy() { if (this.updateTimer) { clearInterval(this.updateTimer); this.updateTimer = null; } if (this._onPointerMove) { document.removeEventListener('pointermove', this._onPointerMove); this._onPointerMove = null; } if (this._onPointerEnd) { document.removeEventListener('pointerup', this._onPointerEnd); document.removeEventListener('pointercancel', this._onPointerEnd); this._onPointerEnd = null; } this._clampToken++; // 使挂起的 clamp setTimeout 因 token 不匹配而失效 document.getElementById('aes-panel-container')?.remove(); this.panel = null; } } // ==================== 核心管理类 ==================== class AutoPlayManager { constructor() { this.video = null; this.isPlaying = false; this.isCompleted = false; this.checkTimer = null; this.currentVideoIndex = -1; this.totalVideos = 0; this.panel = null; this.observer = null; this.userStopped = false; this._videoHandlers = null; this._retryTimer = null; this._retryCount = 0; this._videoItemsCache = null; this._videoItemsCacheTime = 0; this._currentVideoTitle = ''; this._errRetry = 0; this._nextRetry = 0; // [⑪] 目录未加载重试计数 // [R3][R4][R5] 定时器统一句柄化:句柄登记 this._nextTimer = null; // 连播定时器 this._dirRetryTimer = null; // 目录重试定时器 this._startTimer = null; // 启动等待定时器 this._routeTimer = null; // 路由延迟初始化定时器 this._errTimer = null; // 错误重连定时器 this.init(); } init() { log('AES智能云上刷课助手 v1.0.120 [开发灰度预览版] 启动', 'info'); const savedRate = Number(GM_getValue('playbackRate')); if (Number.isFinite(savedRate) && savedRate >= 0.25 && savedRate <= 16) { CONFIG.playbackRate = savedRate; } const savedMute = GM_getValue('autoMute'); if (savedMute !== undefined) CONFIG.autoMute = !!savedMute; this.registerMenuCommands(); if (CONFIG.enableBackgroundPlay) this.setupAntiPause(); this.setupRouteListener(); this.panel = new ControlPanel(this); this.tryInitPlayer(); } registerMenuCommands() { GM_registerMenuCommand('开始/暂停', this.togglePlay.bind(this)); GM_registerMenuCommand('下一课', this.playNextVideo.bind(this)); GM_registerMenuCommand('查看进度', this.showProgress.bind(this)); } setupAntiPause() { try { Object.defineProperty(document, 'hidden', { get: () => false, configurable: true }); Object.defineProperty(document, 'visibilityState', { get: () => 'visible', configurable: true }); } catch (e) {} const forcePlay = () => { if (this.userStopped) return; if (this.video && this.isPlaying && this.video.paused && !this.video.ended) { this.video.play().catch(() => {}); } }; document.addEventListener('visibilitychange', forcePlay); window.addEventListener('blur', forcePlay); document.addEventListener('mouseleave', forcePlay); } setupRouteListener() { let lastHash = location.hash; window.addEventListener('hashchange', () => { if (location.hash !== lastHash) { lastHash = location.hash; log('页面切换: ' + location.hash, 'info'); this.onRouteChange(); } }); } resetPlayerState() { if (this.checkTimer) { clearInterval(this.checkTimer); this.checkTimer = null; } if (this._retryTimer) { clearTimeout(this._retryTimer); this._retryTimer = null; } // [R3][R4][R5] 统一清理所有已纳管定时器,防止停止/切集后被旧回调劫持 if (this._nextTimer) { clearTimeout(this._nextTimer); this._nextTimer = null; } if (this._dirRetryTimer) { clearTimeout(this._dirRetryTimer); this._dirRetryTimer = null; } if (this._startTimer) { clearTimeout(this._startTimer); this._startTimer = null; } if (this._routeTimer) { clearTimeout(this._routeTimer); this._routeTimer = null; } if (this._errTimer) { clearTimeout(this._errTimer); this._errTimer = null; } this._retryCount = 0; this._errRetry = 0; if (this.observer) { try { this.observer.disconnect(); } catch (e) {} this.observer = null; } this.removeVideoEvents(); this.video = null; this.isPlaying = false; this.isCompleted = false; } onRouteChange() { this.resetPlayerState(); this.currentVideoIndex = -1; this._nextRetry = 0; // [⑪] this.invalidateVideoItemsCache(); // [R5] 路由延迟初始化定时器句柄化(旧句柄已由 resetPlayerState 统一清理,不加 token) this._routeTimer = setTimeout(() => { this.updateCurrentVideoIndex(); this.tryInitPlayer(); }, 2000); } // [P12] 1 秒 TTL 缓存,避免每 500ms 全量查询 getAllVideoItems() { const now = Date.now(); if (this._videoItemsCache && (now - this._videoItemsCacheTime) < 1000) { return this._videoItemsCache; } this._videoItemsCache = Array.from(document.querySelectorAll('ul.ant-list-items > div > a')); this._videoItemsCacheTime = now; return this._videoItemsCache; } invalidateVideoItemsCache() { this._videoItemsCache = null; this._videoItemsCacheTime = 0; } // [④] 正则排除 ? 与 #,避免 URL 尾部 query 污染视频 ID getCurrentVideoId() { const match = location.hash.match(/#\/video\/([^/?#]+)/); return match ? match[1] : null; } // [④][总进度冻结] 先重置再精确匹配 + 宽松降级,避免 currentVideoIndex 冻死 updateCurrentVideoIndex() { this.invalidateVideoItemsCache(); const videoItems = this.getAllVideoItems(); const currentId = this.getCurrentVideoId(); this.totalVideos = videoItems.length; // 1) 先重置,匹配不到就明确"未定位",不再保持旧值装死 this.currentVideoIndex = -1; if (currentId) { // 2) 精确匹配优先:#/video/ 段完全相等(后面可跟 ? # 或字符串结尾) const esc = currentId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const exact = new RegExp('#\\/video\\/' + esc + '(?:[?#]|$)'); let found = -1; for (let i = 0; i < videoItems.length; i++) { const href = videoItems[i].getAttribute('href') || ''; if (exact.test(href)) { found = i; break; } // break:取第一个精确匹配 } // 3) 降级:宽松 includes(兼容 href 格式与 hash 不完全一致) if (found === -1) { for (let i = 0; i < videoItems.length; i++) { const href = videoItems[i].getAttribute('href') || ''; if (href.includes(currentId)) { found = i; break; } } } this.currentVideoIndex = found; } this._currentVideoTitle = (this.currentVideoIndex >= 0 && videoItems[this.currentVideoIndex]) ? (videoItems[this.currentVideoIndex].textContent?.trim().split('时长')[0] || '') : ''; log(`当前视频索引: ${this.currentVideoIndex + 1}/${this.totalVideos}`, 'info'); } // [③][P10] 防 observer 累积 + findVideo 只调用一次 + 僵尸元素校验 tryInitPlayer() { if (!location.hash.includes('/video/')) return; if (this.observer) { try { this.observer.disconnect(); } catch (e) {} this.observer = null; } log('正在查找视频播放器...', 'info'); const isValid = (el) => { if (!el) return false; const doc = el.ownerDocument; return !!(doc && doc.body && doc.body.contains(el)); }; const findVideo = () => { let v = document.querySelector('#CuPlayer video') || document.querySelector('.vjs-tech') || document.querySelector('video'); if (v && !isValid(v)) v = null; if (!v) { try { const iframes = document.querySelectorAll('iframe'); for (let iframe of iframes) { if (iframe.contentDocument) { const cand = iframe.contentDocument.querySelector('video'); if (cand && isValid(cand)) { v = cand; break; } } } } catch (e) {} } return v; }; const v0 = findVideo(); if (v0) { this.video = v0; this.onPlayerReady(); return; } const obs = new MutationObserver(() => { const v = findVideo(); if (v) { try { obs.disconnect(); } catch (e) {} if (this.observer === obs) this.observer = null; this.video = v; this.onPlayerReady(); } }); this.observer = obs; obs.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { if (this.observer === obs) { try { obs.disconnect(); } catch (e) {} this.observer = null; if (!this.video) log('未找到视频播放器(超时)', 'error'); } }, 15000); } onPlayerReady() { log('视频播放器已就绪', 'success'); this.updateCurrentVideoIndex(); this.lockPlaybackRate(this.video); if (CONFIG.autoMute) this.video.muted = true; this.setupVideoEvents(); // [R5] 启动等待定时器句柄化,防止陈旧回调重复启动(时序不变) this._startTimer = setTimeout(() => this.startAutoPlay(), CONFIG.startWaitTime); } // [②][⑥] 倍速防护锁:管住脚本管控的 video,放过其他 video;原型锁只装一次 lockPlaybackRate(videoElement) { videoElement._aes_managed = true; if (!_protoLocked) { try { _nativePlaybackRateDesc = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'playbackRate'); const nativeGet = _nativePlaybackRateDesc.get; const nativeSet = _nativePlaybackRateDesc.set; Object.defineProperty(HTMLVideoElement.prototype, 'playbackRate', { get: function() { if (this._aes_managed === true) { return this._aes_rate !== undefined ? this._aes_rate : CONFIG.playbackRate; } return nativeGet.call(this); }, set: function(val) { if (this._aes_managed === true) { if (this._aes_setting) { this._aes_rate = val; this._aes_setting = false; } else { this._aes_rate = CONFIG.playbackRate; } nativeSet.call(this, this._aes_rate); } else { nativeSet.call(this, val); } }, configurable: true }); _protoLocked = true; log('已启用倍速防护锁: ' + CONFIG.playbackRate + 'x', 'success'); } catch (e) { log('原型锁安装失败,使用事件兜底', 'warn'); } } videoElement._aes_setting = true; videoElement.playbackRate = CONFIG.playbackRate; } applyPlaybackRate() { if (this.video) { this.video._aes_setting = true; this.video.playbackRate = CONFIG.playbackRate; } } removeVideoEvents() { if (!this._videoHandlers) return; const { video, handlers } = this._videoHandlers; try { video.removeEventListener('play', handlers.play); video.removeEventListener('pause', handlers.pause); video.removeEventListener('ended', handlers.ended); video.removeEventListener('error', handlers.error); video.removeEventListener('ratechange', handlers.ratechange); } catch (e) {} this._videoHandlers = null; } setupVideoEvents() { if (!this.video) return; this.removeVideoEvents(); const video = this.video; const handlers = { play: () => { this.isPlaying = true; this._errRetry = 0; }, pause: () => { if (!this.video || this.video !== video) return; if (this.userStopped) return; if (!video.ended && this.isPlaying) { setTimeout(() => { if (this.video === video && video.paused && !video.ended && !this.userStopped) { video.play().catch(() => {}); } }, 300); } }, ended: () => this.onVideoEnded(), error: () => { // [④] 错误重连:次数上限 + 指数退避 if (!this.video || this.video !== video) return; this._errRetry = (this._errRetry || 0) + 1; if (this._errRetry > 5) { // [R2] 仅首次超限通知一次,并掐断 checkVideoStatus 的重试驱动,杜绝通知风暴 if (this._errRetry === 6) { log('视频源重连失败次数达上限,停止重试', 'error'); notify('播放中断', '重连超过 5 次已停止,请检查视频源'); this.isPlaying = false; } return; } const delay = Math.min(3000 * Math.pow(1.5, this._errRetry - 1), 30000); log(`视频播放错误,${Math.round(delay / 1000)}秒后尝试第 ${this._errRetry} 次重连`, 'warn'); // [R3/R4/R5 定时器家族] 重连定时器句柄化 + userStopped 守卫,防止停止后被旧回调复活播放 if (this._errTimer) clearTimeout(this._errTimer); this._errTimer = setTimeout(() => { if (this.userStopped) return; if (this.video === video) { try { video.load(); } catch (e) {} video.play().catch(() => {}); } }, delay); }, // [⑥] ratechange 无条件兜底 ratechange: () => { if (this.video !== video) return; const realRate = _nativePlaybackRateDesc ? _nativePlaybackRateDesc.get.call(video) : video.playbackRate; if (Math.abs(realRate - CONFIG.playbackRate) > 0.01) { log('检测到速率被外部篡改,自动扳回 ' + CONFIG.playbackRate + 'x', 'warn'); video._aes_setting = true; video.playbackRate = CONFIG.playbackRate; } } }; this._videoHandlers = { video, handlers }; video.addEventListener('play', handlers.play); video.addEventListener('pause', handlers.pause); video.addEventListener('ended', handlers.ended); video.addEventListener('error', handlers.error); video.addEventListener('ratechange', handlers.ratechange); } // [③] 自动播放失败不再静默死亡:第二次失败后进入自愈模式 startAutoPlay() { if (this.userStopped) return; if (!this.video) { this.tryInitPlayer(); return; } this.applyPlaybackRate(); if (CONFIG.autoMute) this.video.muted = true; const total = this.getAllVideoItems().length; const idx = this.currentVideoIndex >= 0 ? this.currentVideoIndex + 1 : 0; const onSuccess = (muted) => { this.isPlaying = true; this._errRetry = 0; this.startStatusCheck(); notify('刷课已开始' + (muted ? '(静音)' : ''), `第 ${idx}/${total} 个视频`); }; this.video.play().then(() => onSuccess(false)).catch(err => { log('自动播放失败,强制静音重试: ' + err.message, 'warn'); if (!this.video) return; this.video.muted = true; this.video.play().then(() => onSuccess(true)).catch(err2 => { log('自动播放两次失败,进入自愈模式(等待用户交互或切页触发恢复): ' + err2.message, 'warn'); this.isPlaying = true; this.startStatusCheck(); }); }); } stopAutoPlay() { this.userStopped = true; if (this.checkTimer) { clearInterval(this.checkTimer); this.checkTimer = null; } if (this._retryTimer) { clearTimeout(this._retryTimer); this._retryTimer = null; } // [R3][R4][R5] 统一清理:停止后所有未决定时器一律作废,防止旧回调劫持 if (this._nextTimer) { clearTimeout(this._nextTimer); this._nextTimer = null; } if (this._dirRetryTimer) { clearTimeout(this._dirRetryTimer); this._dirRetryTimer = null; } if (this._startTimer) { clearTimeout(this._startTimer); this._startTimer = null; } if (this._routeTimer) { clearTimeout(this._routeTimer); this._routeTimer = null; } if (this._errTimer) { clearTimeout(this._errTimer); this._errTimer = null; } this._retryCount = 0; this._errRetry = 0; if (this.video) this.video.pause(); this.isPlaying = false; notify('刷课已停止', '脚本已暂停运行,点击 ▶ 可恢复'); } // [P7] 面板暂停键 = 脚本启停开关 // [R1] 修复死锁:userStopped 与运行态分支互斥,恢复路径重新可达 togglePlay() { if (this.userStopped) { this.userStopped = false; this.startAutoPlay(); } else { this.stopAutoPlay(); } } toggleMute() { if (!this.video) return; this.video.muted = !this.video.muted; CONFIG.autoMute = this.video.muted; GM_setValue('autoMute', CONFIG.autoMute); } setSpeed(speed) { CONFIG.playbackRate = speed; GM_setValue('playbackRate', speed); this.applyPlaybackRate(); notify('速度已设置', `播放速度: ${speed}x`); } startStatusCheck() { if (this.checkTimer) clearInterval(this.checkTimer); this.checkTimer = setInterval(() => this.checkVideoStatus(), CONFIG.checkInterval); } checkVideoStatus() { if (this.userStopped) return; if (!this.video) { this.tryInitPlayer(); return; } if (this.video.paused && !this.video.ended && this.isPlaying) this.video.play().catch(() => {}); } onVideoEnded() { if (this.isCompleted) return; if (this.userStopped) return; this.isCompleted = true; log('视频播放完成!', 'success'); const total = this.getAllVideoItems().length; const idx = this.currentVideoIndex >= 0 ? this.currentVideoIndex + 1 : 0; notify('课程已完成', `第 ${idx}/${total} 个视频播放完成`); if (this.checkTimer) { clearInterval(this.checkTimer); this.checkTimer = null; } // [R3] 连播定时器句柄化 + userStopped 守卫:正常连播时序与 DP4 完全一致,停止后不再被劫持 this._nextTimer = setTimeout(() => { if (this.userStopped) return; this.playNextVideo(); }, CONFIG.nextVideoDelay); } // [②] 切集后有限重试初始化播放器 schedulePlayerInit() { if (this._retryTimer) { clearTimeout(this._retryTimer); this._retryTimer = null; } this._retryCount = 0; const attempt = () => { if (this.video) { this._retryTimer = null; return; } this._retryCount++; this.updateCurrentVideoIndex(); this.tryInitPlayer(); if (this._retryCount < 10) { this._retryTimer = setTimeout(attempt, 1500); } else { this._retryTimer = null; log('播放器初始化失败(已重试 ' + this._retryCount + ' 次)', 'error'); } }; this._retryTimer = setTimeout(attempt, 1500); } // [⑪] 目录未加载时轻量重试 playNextVideo() { this.updateCurrentVideoIndex(); const videoItems = this.getAllVideoItems(); if (videoItems.length === 0) { this._nextRetry = (this._nextRetry || 0) + 1; if (this._nextRetry > 10) { this._nextRetry = 0; notify('切换失败', '课程目录未加载,已重试 10 次'); return; } log(`目录未加载,${1.5}秒后第 ${this._nextRetry} 次重试`, 'warn'); // [R4] 目录重试定时器句柄化 + userStopped 守卫:重试节奏不变,停止后让路 this._dirRetryTimer = setTimeout(() => { if (this.userStopped) return; this.playNextVideo(); }, 1500); return; } this._nextRetry = 0; const nextIndex = this.currentVideoIndex + 1; if (nextIndex >= videoItems.length) { notify('🎉 刷课完成!', '所有视频已播放完毕'); return; } const nextVideo = videoItems[nextIndex]; const videoName = nextVideo.textContent?.trim().split('时长')[0] || `第${nextIndex + 1}个视频`; let currentHash = location.hash; // 【双保险切课:点击 + 500ms 后强制 hash 跳转,未做改动】 nextVideo.click(); setTimeout(() => { if (location.hash === currentHash) { const href = nextVideo.getAttribute('href'); if (href) { log('点击切换失效,执行强制Hash跳转', 'warn'); location.hash = href; } } }, 500); notify('切换视频', `正在播放: ${videoName}`); this.userStopped = false; this.resetPlayerState(); this.schedulePlayerInit(); } // [⑪][R4] 与 playNextVideo 保持一致的目录重试 playPrevVideo() { this.updateCurrentVideoIndex(); const videoItems = this.getAllVideoItems(); if (videoItems.length === 0) { this._nextRetry = (this._nextRetry || 0) + 1; if (this._nextRetry > 10) { this._nextRetry = 0; notify('切换失败', '课程目录未加载,已重试 10 次'); return; } // [R4] 句柄化 + userStopped 守卫 this._dirRetryTimer = setTimeout(() => { if (this.userStopped) return; this.playPrevVideo(); }, 1500); return; } this._nextRetry = 0; const prevIndex = this.currentVideoIndex - 1; if (prevIndex < 0) { notify('提示', '已经是第一个视频了'); return; } const prevVideo = videoItems[prevIndex]; let currentHash = location.hash; prevVideo.click(); setTimeout(() => { if (location.hash === currentHash) { const href = prevVideo.getAttribute('href'); if (href) location.hash = href; } }, 500); this.userStopped = false; this.resetPlayerState(); this.schedulePlayerInit(); } // [R8] 去除冗余目录查询(updateCurrentVideoIndex 内部已刷新 totalVideos); // 未定位时以 0 显示,避免异常集数 showProgress() { this.updateCurrentVideoIndex(); const current = Math.max(0, this.currentVideoIndex + 1); const total = this.totalVideos; const percent = total > 0 ? ((current / total) * 100).toFixed(1) : 0; let timeInfo = this.video ? `\n当前: ${this.formatTime(this.video.currentTime)} / ${this.formatTime(this.video.duration)}` : ''; notify('当前进度', `第 ${current}/${total} 个视频 (${percent}%)${timeInfo}`); } formatTime(seconds) { if (!seconds || isNaN(seconds)) return '00:00'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); return h > 0 ? `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` : `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } } // ==================== 启动 ==================== if (document.readyState === 'complete') { new AutoPlayManager(); } else { window.addEventListener('load', () => new AutoPlayManager()); } })();