// ==UserScript== // @name 分享一个无障碍视频助手小工具 // @namespace a11y-video-helper // @version 3.2.0 // @description 本学习助手支持智能识别学习进度,能够自动筛选“已播放”与“未播放”状态,并支持一键跳转至下一章节,为多视频、多章节课程提供直观的可视化管理体验。 // @author Assistant // @match *://*.chaoxing.com/* // @match *://*.zhihuishu.com/* // @match *://*.edu.cn/* // @match *://*/* // @grant none // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; // ============================================================ // 配置 // ============================================================ const CONFIG = { seekSeconds: 10, speedStep: 0.25, minSpeed: 0.5, maxSpeed: 2.5, tag: 'data-a11y-initialized', watchedThreshold: 0.85, scanInterval: 2000, persistInterval: 3000, storagePrefix: 'a11y-video-helper:', nextChapterSelectors: [ '.jb_btn.jb_btn_92.fr.fs14.nextChapter', '#prevNextFocusNext', '.nextChapter', '.next-lesson', '.next-page', '.next', 'a[title="下一章"]', '.course-btn-next', '.next-chapter', '.chapter_next', '.next_chapter', '.nextLesson', '.chapter-next', '.course-next', '.lesson-next' ] }; function formatTime(seconds) { if (!isFinite(seconds) || seconds < 0) return '0:00'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); if (h > 0) { return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } return `${m}:${s.toString().padStart(2, '0')}`; } function getStatusText(v, info) { if (!v) return '未找到'; if (v.ended || info?.watched) return '已看完'; if (!v.paused) return '播放中'; if ((info?.currentTime || v.currentTime) > 3) return `看到 ${formatTime(info?.currentTime || v.currentTime)}`; return '未播放'; } function isVisible(element) { if (!element) return false; if (typeof element.getBoundingClientRect !== 'function') return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight && rect.bottom > 0 && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; } const StorageManager = { get pageKey() { return window.location.href; }, _buildKey(videoKey, field) { return `${CONFIG.storagePrefix}${this.pageKey}::${videoKey}::${field}`; }, set(videoKey, field, value) { try { localStorage.setItem(this._buildKey(videoKey, field), JSON.stringify(value)); } catch (e) { console.warn('[视频助手] 写入 localStorage 失败', e); } }, get(videoKey, field) { try { const raw = localStorage.getItem(this._buildKey(videoKey, field)); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } }, clearAll() { Object.keys(localStorage).forEach(key => { if (key.startsWith(CONFIG.storagePrefix)) { localStorage.removeItem(key); } }); }, setGlobal(key, value) { try { localStorage.setItem(`${CONFIG.storagePrefix}global::${key}`, JSON.stringify(value)); } catch (e) {} }, getGlobal(key) { try { const raw = localStorage.getItem(`${CONFIG.storagePrefix}global::${key}`); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } } }; function getVideoKey(video) { const src = video.currentSrc || video.src || ''; if (src) { return `src:${src}`; } let text = ''; let container = video.closest('[class]') || video.parentElement; if (container) { const heading = container.querySelector('h1,h2,h3,h4,h5,h6,span[class*="title"],div[class*="title"]'); text = (heading ? heading.textContent : container.textContent).trim().slice(0, 80); } const rect = video.getBoundingClientRect(); return `text:${text}:pos:${Math.round(rect.top)}:${Math.round(rect.left)}`; } class VideoManager { constructor() { this.videos = new Map(); this.lastInteracted = null; this.scanTimer = null; this.pendingSaves = new Map(); } start() { this.scan(); this.scanTimer = setInterval(() => this.scan(), CONFIG.scanInterval); } stop() { if (this.scanTimer) { clearInterval(this.scanTimer); this.scanTimer = null; } } scan(root = document) { this.findInDocument(root); const iframes = root.querySelectorAll('iframe'); iframes.forEach(iframe => { try { if (iframe.contentDocument && iframe.contentDocument.readyState === 'complete') { this.scan(iframe.contentDocument); this.observeIframe(iframe); } } catch (e) { } }); } observeIframe(iframe) { if (iframe._a11yObserved) return; iframe._a11yObserved = true; iframe.addEventListener('load', () => { setTimeout(() => this.scan(), 500); }); } findInDocument(doc) { doc.querySelectorAll('video').forEach(v => this.register(v)); } register(video) { const videoKey = getVideoKey(video); if (video.hasAttribute(CONFIG.tag)) { this.updateStatus(video); return; } video.setAttribute(CONFIG.tag, '1'); const saved = StorageManager.get(videoKey, 'state'); const info = { key: videoKey, element: video, watched: saved?.watched || false, currentTime: saved?.currentTime || 0, duration: saved?.duration || 0, updatedAt: saved?.updatedAt || 0 }; this.videos.set(video, info); video.addEventListener('play', () => { this.lastInteracted = video; }); video.addEventListener('pause', () => { this.lastInteracted = video; this.persist(video); }); video.addEventListener('timeupdate', () => { this.updateStatus(video); this.schedulePersist(video); }); video.addEventListener('ended', () => { const info = this.videos.get(video); info.watched = true; info.currentTime = video.currentTime; info.duration = video.duration || 0; this.persist(video); if (window.a11yUI) window.a11yUI.speak('当前视频已播放完毕'); }); video.addEventListener('click', () => { this.lastInteracted = video; }); } updateStatus(video) { const info = this.videos.get(video); if (!info) return; info.currentTime = video.currentTime || info.currentTime; info.duration = video.duration || info.duration; if (!info.watched && video.duration > 0 && video.currentTime / video.duration > CONFIG.watchedThreshold) { info.watched = true; } } schedulePersist(video) { if (this.pendingSaves.has(video)) return; this.pendingSaves.set(video, setTimeout(() => { this.pendingSaves.delete(video); this.persist(video); }, CONFIG.persistInterval)); } persist(video) { const info = this.videos.get(video); if (!info) return; StorageManager.set(info.key, 'state', { watched: info.watched, currentTime: video.currentTime, duration: video.duration, updatedAt: Date.now() }); } persistAll() { this.videos.forEach((info, video) => this.persist(video)); } getAll() { this.scan(); return Array.from(this.videos.keys()); } getActive() { const all = this.getAll(); if (all.length === 0) return null; const playing = all.find(v => !v.paused && !v.ended); if (playing) return playing; if (this.lastInteracted && all.includes(this.lastInteracted)) { return this.lastInteracted; } for (const v of all) { const rect = v.getBoundingClientRect(); if (rect.top >= 0 && rect.bottom <= window.innerHeight) { return v; } } return all[all.length - 1]; } getNextUnwatched() { this.getAll(); const all = Array.from(this.videos.keys()); if (all.length === 0) return null; all.forEach(v => this.updateStatus(v)); const active = this.getActive(); let startIndex = active ? all.indexOf(active) : -1; if (startIndex < 0) startIndex = 0; for (let i = 1; i <= all.length; i++) { const index = (startIndex + i) % all.length; const v = all[index]; const info = this.videos.get(v); if (!info?.watched && !v.ended) { return v; } } return null; } } const videoManager = new VideoManager(); const CSS = ` :root { --a11y-primary: #1f71e0; --a11y-primary-hover: #1557b0; --a11y-success: #10b981; --a11y-warning: #f59e0b; --a11y-danger: #ef4444; --a11y-bg: #ffffff; --a11y-text: #1f2329; --a11y-border: #e5e7eb; } #a11y-panel { position: fixed; right: 16px; bottom: 16px; z-index: 2147483647; width: 320px; max-height: 85vh; background: var(--a11y-bg); border: 3px solid var(--a11y-primary); border-radius: 14px; box-shadow: 0 10px 40px rgba(0,0,0,0.18); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Microsoft YaHei", sans-serif; color: var(--a11y-text); overflow: hidden; display: flex; flex-direction: column; } #a11y-header { background: var(--a11y-primary); color: #fff; padding: 14px 16px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; user-select: none; } #a11y-header h2 { margin: 0; font-size: 17px; font-weight: 700; } #a11y-header .a11y-icon { font-size: 18px; transition: transform 0.2s; } #a11y-panel.collapsed #a11y-header .a11y-icon { transform: rotate(-90deg); } #a11y-body { padding: 14px; overflow-y: auto; max-height: 70vh; } #a11y-panel.collapsed #a11y-body { display: none; } .a11y-section { margin-bottom: 16px; } .a11y-section-title { font-size: 13px; color: #6b7280; margin-bottom: 10px; font-weight: 700; } .a11y-btn { border: none; border-radius: 10px; padding: 14px 8px; font-size: 15px; font-weight: 700; cursor: pointer; transition: all 0.15s ease; min-height: 52px; text-align: center; width: 100%; margin-bottom: 8px; } .a11y-btn:focus-visible, .a11y-btn:focus { outline: 3px solid var(--a11y-warning); outline-offset: 2px; } .a11y-btn-primary { background: var(--a11y-primary); color: #fff; } .a11y-btn-primary:hover { background: var(--a11y-primary-hover); } .a11y-btn-success { background: var(--a11y-success); color: #fff; } .a11y-btn-success:hover { background: #059669; } .a11y-btn-secondary { background: #f3f4f6; color: var(--a11y-text); } .a11y-btn-secondary:hover { background: #e5e7eb; } .a11y-btn-warning { background: var(--a11y-warning); color: #fff; } .a11y-btn-warning:hover { background: #d97706; } .a11y-btn-danger { background: var(--a11y-danger); color: #fff; } .a11y-btn-danger:hover { background: #b91c1c; } .a11y-btn:disabled { background: #d1d5db; cursor: not-allowed; } .a11y-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } .a11y-grid .a11y-btn { margin-bottom: 0; } .a11y-status-box { background: #f3f4f6; border-radius: 10px; padding: 12px; font-size: 13px; line-height: 1.8; } #a11y-video-list { list-style: none; margin: 0; padding: 0; max-height: 180px; overflow-y: auto; border: 1px solid var(--a11y-border); border-radius: 10px; } .a11y-video-item { padding: 10px 12px; border-bottom: 1px solid var(--a11y-border); font-size: 13px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; transition: background 0.15s; } .a11y-video-item:last-child { border-bottom: none; } .a11y-video-item:hover, .a11y-video-item:focus { background: #eff6ff; } .a11y-video-item.watched { color: #6b7280; } .a11y-video-item.watched .a11y-v-title { text-decoration: line-through; } .a11y-video-item.watched::after { content: "✓ 已看完"; color: var(--a11y-success); font-weight: 700; font-size: 12px; } .a11y-video-item.playing { background: #dbeafe; font-weight: 700; } .a11y-video-item .a11y-v-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; margin-right: 8px; } .a11y-shortcuts { background: #f9fafb; border-radius: 10px; padding: 12px; font-size: 12px; color: #4b5563; line-height: 1.8; } .a11y-shortcuts kbd { background: #fff; border: 1px solid #d1d5db; border-radius: 4px; padding: 2px 6px; font-family: monospace; font-weight: 700; } .a11y-voice-toast { position: fixed; top: 16px; left: 50%; transform: translateX(-50%); background: var(--a11y-danger); color: #fff; padding: 12px 24px; border-radius: 24px; font-size: 15px; font-weight: 700; z-index: 2147483647; display: none; align-items: center; gap: 8px; } .a11y-voice-toast.listening { display: flex; } @media (prefers-reduced-motion: reduce) { .a11y-icon, .a11y-btn, .a11y-video-item { transition: none !important; } } `; /** let recognition = null; function initVoice() { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) return; recognition = new SpeechRecognition(); recognition.lang = 'zh-CN'; recognition.continuous = true; recognition.interimResults = false; recognition.onresult = (event) => { const text = event.results[event.results.length - 1][0].transcript.trim(); handleVoiceCommand(text); }; recognition.onerror = () => { speak('语音识别出错,请重试'); }; recognition.onend = () => { const toast = document.querySelector('.a11y-voice-toast'); if (toast && toast.classList.contains('listening')) { recognition.start(); } }; }*/ function handleVoiceCommand(text) { speak(`收到:${text}`); if (text.includes('播放') || text.includes('暂停')) togglePlay(); else if (text.includes('停止')) stopVideo(); else if (text.includes('前进')) seek(CONFIG.seekSeconds); else if (text.includes('后退')) seek(-CONFIG.seekSeconds); else if (text.includes('加速')) changeSpeed(CONFIG.speedStep); else if (text.includes('减速')) changeSpeed(-CONFIG.speedStep); else if (text.includes('全屏')) toggleFullscreen(); else if (text.includes('静音')) toggleMute(); else if (text.includes('下一个')) goToNextUnwatched(); else if (text.includes('下一章') || text.includes('下一节') || text.includes('下一集')) goToNextChapter(); } function toggleVoice() { const toast = document.querySelector('.a11y-voice-toast'); if (!recognition) { speak('当前浏览器不支持语音识别'); return; } if (toast.classList.contains('listening')) { recognition.stop(); toast.classList.remove('listening'); document.getElementById('a11y-voice-btn').textContent = '🎤 语音控制'; } else { recognition.start(); toast.classList.add('listening'); document.getElementById('a11y-voice-btn').textContent = '🎤 停止语音'; speak('语音控制已开启'); } } function speak(text) { if (!window.speechSynthesis) return; const u = new SpeechSynthesisUtterance(text); u.lang = 'zh-CN'; window.speechSynthesis.speak(u); } function getActive() { return videoManager.getActive(); } function togglePlay() { const v = getActive(); if (!v) { speak('未检测到视频'); return; } if (v.paused) { v.play().catch(() => speak('播放失败,请尝试点击页面内播放按钮')); } else { v.pause(); } } function stopVideo() { const v = getActive(); if (!v) return; v.pause(); v.currentTime = 0; } function seek(seconds) { const v = getActive(); if (!v) return; v.currentTime = Math.max(0, Math.min(v.duration || 0, v.currentTime + seconds)); } function changeSpeed(delta) { const v = getActive(); if (!v) return; v.playbackRate = Math.max(CONFIG.minSpeed, Math.min(CONFIG.maxSpeed, v.playbackRate + delta)); } function toggleFullscreen() { const v = getActive(); if (!v) return; if (document.fullscreenElement) { document.exitFullscreen(); } else { v.requestFullscreen().catch(() => {}); } } function toggleMute() { const v = getActive(); if (!v) return; v.muted = !v.muted; } function findNextChapterButton() { for (const selector of CONFIG.nextChapterSelectors) { try { const buttons = Array.from(document.querySelectorAll(selector)); const visibleBtn = buttons.find(btn => isVisible(btn)); if (visibleBtn) return visibleBtn; } catch (e) { continue; } } const keywords = ['下一章', '下一节', '下一集', 'next', '继续学习']; const candidates = Array.from(document.querySelectorAll('a, button, span, div')).filter(el => { if (!isVisible(el)) return false; const text = el.textContent.trim(); return keywords.some(k => text.toLowerCase().includes(k.toLowerCase())); }); return candidates[0] || null; } function goToNextChapter() { const btn = findNextChapterButton(); if (!btn) { speak('未找到下一章节按钮,请手动查找'); return; } const originalOutline = btn.style.outline; const originalOffset = btn.style.outlineOffset; btn.style.outline = '4px solid #f59e0b'; btn.style.outlineOffset = '4px'; setTimeout(() => { btn.style.outline = originalOutline; btn.style.outlineOffset = originalOffset; }, 2000); btn.click(); speak('已点击下一章节按钮'); } function goToNextUnwatched() { const next = videoManager.getNextUnwatched(); if (!next) { speak('没有更多未播放的视频了'); return; } videoManager.lastInteracted = next; next.scrollIntoView({ behavior: 'smooth', block: 'center' }); next.focus(); next.style.outline = '4px solid #f59e0b'; next.style.outlineOffset = '4px'; setTimeout(() => { next.style.outline = ''; next.style.outlineOffset = ''; }, 3000); speak('已定位到下一个未播放视频,请手动点击播放'); } // ============================================================ // UI // ============================================================ function createPanel() { if (document.getElementById('a11y-panel')) return; const style = document.createElement('style'); style.textContent = CSS; document.head.appendChild(style); const panel = document.createElement('div'); panel.id = 'a11y-panel'; panel.innerHTML = `