中国大学MOOC(慕课)功能增强 Chinese University MOOC Enhancer

一个旨在增强中国大学MOOC的视频学习体验的油猴脚本。

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         中国大学MOOC(慕课)功能增强 Chinese University MOOC Enhancer
// @name:zh-CN   中国大学MOOC(慕课)功能增强
// @icon         https://edu-image.nosdn.127.net/32a8dd2a-b9aa-4ec9-abd5-66cd8751befb.png?imageView&quality=100
// @namespace    https://github.com/zhumengstarsandsea/Chinese_University_MOOC_Enhancer
// @version      8.1.2
// @description  一个旨在增强中国大学MOOC的视频学习体验的油猴脚本。
// @description:zh-cn  一个旨在增强中国大学MOOC的视频学习体验的油猴脚本。
// @author       zhumengstarsandsea
// @license      AGPL-3.0-only
// @match        https://www.icourse163.org/learn/*
// @match        https://www.icourse163.org/spoc/learn/*
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    // =================================================================================
    // == SECTION 1: 全局样式注入 (CSS Injection)
    // =================================================================================
    GM_addStyle(`
        .rh-nav-button:disabled { background-color: #f0f0f0 !important; border-color: #e0e0e0 !important; color: #aaa !important; cursor: not-allowed !important; }
        body, body * { -webkit-user-select: auto !important; -moz-user-select: auto !important; -ms-user-select: auto !important; user-select: auto !important; }
        #rh-top-container { display: flex; align-items: center; margin-left: 20px; }
        #rh-top-container label { font-size: 14px; font-weight: normal; color: #000000; user-select: none; margin-right: 10px; }
        #rh-top-container input[type="number"] { width: 45px; height: 24px; border: 1px solid #000000; border-radius: 4px; background-color: #FFFFFF; color: #000000; text-align: center; font-size: 14px; -moz-appearance: textfield; }
        #rh-top-container input[type="range"] { -webkit-appearance: none; appearance: none; width: 120px; background: transparent; margin-right: 10px; padding: 5px 0; }
        #rh-top-container input[type="range"]:focus { outline: none; }
        #rh-top-container input[type="range"]::-webkit-slider-runnable-track { width: 100%; height: 6px; cursor: pointer; border-radius: 3px; border: 1px solid #ccc; }
        #rh-top-container input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; height: 18px; width: 18px; border-radius: 50%; background: #FFFFFF; border: 2px solid #27cc7e; cursor: pointer; margin-top: -7px; }
        #rh-top-container input[type="range"]::-moz-range-track { width: 100%; height: 6px; cursor: pointer; border-radius: 3px; border: 1px solid #ccc; }
        #rh-top-container input[type="range"]::-moz-range-thumb { height: 18px; width: 18px; border-radius: 50%; background: #FFFFFF; border: 2px solid #27cc7e; cursor: pointer; }
        #rh-nav-container { display: flex; align-items: center; margin-left: 15px; }
        .rh-nav-button { padding: 2px 10px; font-size: 12px; color: #333; background-color: #f0f0f0; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; margin: 0 4px; user-select: none; transition: background-color 0.2s, border-color 0.2s; }
        .rh-nav-button:hover:not(:disabled) { background-color: #e0e0e0; border-color: #999; }
        .rh-nav-button:active:not(:disabled) { background-color: #d0d0d0; }
    `);
    const sliderStyle = document.createElement('style');
    sliderStyle.id = 'rh-slider-style';
    document.head.appendChild(sliderStyle);
    sliderStyle.innerHTML = `
        #rh-gain-slider::-webkit-slider-runnable-track { background: linear-gradient(to right, #27cc7e, #27cc7e 0%, #FFFFFF 0%) !important; }
        #rh-gain-slider::-moz-range-track { background: linear-gradient(to right, #27cc7e, #27cc7e 0%, #FFFFFF 0%) !important; }
    `;

    // =================================================================================
    // == SECTION 2: 核心功能模块 (Core Feature Modules) - v8.1.2
    // =================================================================================

    const enhancerModule = {
        namespace: window._rh || {},
        activeObservers: [], errorObserver: null,
        GAIN_STORAGE_KEY: "rh_gain_data", AUTOPLAY_STORAGE_KEY: 'rh_autoplay_state',
        BOUNDARY_CACHE_PREFIX: "rh_boundary_cache_v5_",
        isCheckingBoundary: false, lastNavigationDirection: 0,
        courseId: null, termId: null, navigationSessionId: null,

        log(message, type = 'info') {
            const style = { info: 'color: #03a9f4;', success: 'color: #27cc7e; font-weight: bold;', warn: 'color: #ffc107;', error: 'color: #dc3545; font-weight: bold;',} [type];
            console.log(`%c[全功能增强脚本 v8.1.2] ${message}`, style);
        },

        formatRemainingTime(ms) {
            if (ms <= 0) return "已过期";
            const days = Math.floor(ms / 86400000);
            const hours = Math.floor((ms % 86400000) / 3600000);
            const minutes = Math.floor((ms % 3600000) / 60000);
            return `${days}天${hours}小时${minutes}分钟`;
        },

        getCourseId: function() { return location.pathname.match(/\/learn\/([^\?\/]+)/)?.[1] || null; },
        getTermId: function() { return new URLSearchParams(window.location.search).get('tid') || null; },

        getBoundaryCache: function(courseId, termId) {
            if (!courseId || !termId) return {};
            const key = this.BOUNDARY_CACHE_PREFIX + courseId;
            const rawData = localStorage.getItem(key);
            if (!rawData) return {};

            try {
                let courseData = JSON.parse(rawData);
                const COURSE_EXPIRY = 90 * 24 * 60 * 60 * 1000;
                const TERM_EXPIRY = 30 * 24 * 60 * 60 * 1000;
                const VIDEO_EXPIRY = 7 * 24 * 60 * 60 * 1000;
                let dataModified = false;

                if ((Date.now() - courseData.timestamp) > COURSE_EXPIRY) {
                    this.log(`[边界缓存] 课程ID ${courseId} 的整体缓存已过期(>90天),记录于 ${new Date(courseData.timestamp).toLocaleString('zh-CN')}。缓存已清除。`, 'warn');
                    localStorage.removeItem(key);
                    return {};
                }
                this.log(`[边界缓存] 命中课程ID ${courseId} 的缓存 (剩余有效期: ${this.formatRemainingTime(COURSE_EXPIRY - (Date.now() - courseData.timestamp))})`, 'success');

                let termData = courseData.terms[termId];
                if (!termData) return {};
                if ((Date.now() - termData.timestamp) > TERM_EXPIRY) {
                    this.log(`[边界缓存] 学期ID ${termId} 的缓存已过期(>30天),记录于 ${new Date(termData.timestamp).toLocaleString('zh-CN')}。此学期缓存已清除。`, 'warn');
                    delete courseData.terms[termId];
                    localStorage.setItem(key, JSON.stringify(courseData));
                    return {};
                }
                this.log(`[边界缓存] 命中学期ID ${termId} 的缓存 (剩余有效期: ${this.formatRemainingTime(TERM_EXPIRY - (Date.now() - termData.timestamp))})`, 'success');

                const cleanedVideos = {};
                let videosPurgedCount = 0;
                for (const videoId in termData.videos) {
                    const videoEntry = termData.videos[videoId];
                    if ((Date.now() - videoEntry.timestamp) < VIDEO_EXPIRY) {
                        cleanedVideos[videoId] = videoEntry.status;
                    } else {
                        videosPurgedCount++;
                        this.log(`[边界缓存] 视频ID ${videoId} 的缓存条目已过期(>7天),记录于 ${new Date(videoEntry.timestamp).toLocaleString('zh-CN')}。`, 'warn');
                    }
                }

                if (videosPurgedCount > 0) {
                    const newVideoCacheForTerm = {};
                    for (const videoId in termData.videos) {
                         if (cleanedVideos[videoId]) {
                            newVideoCacheForTerm[videoId] = termData.videos[videoId];
                         }
                    }
                    courseData.terms[termId].videos = newVideoCacheForTerm;
                    dataModified = true;
                }
                if (dataModified) localStorage.setItem(key, JSON.stringify(courseData));

                return cleanedVideos;

            } catch (e) {
                this.log(`[边界缓存] 解析课程ID ${courseId} 的缓存失败,已自动清除。错误: ${e}`, 'error');
                localStorage.removeItem(key);
                return {};
            }
        },

        setBoundaryCache: function(courseId, termId, newVideoEntry) {
            if (!courseId || !termId || !newVideoEntry) return;
            const key = this.BOUNDARY_CACHE_PREFIX + courseId;
            const rawData = localStorage.getItem(key);
            let courseData;

            try {
                courseData = rawData ? JSON.parse(rawData) : { timestamp: Date.now(), terms: {} };
            } catch (e) {
                courseData = { timestamp: Date.now(), terms: {} };
            }

            courseData.timestamp = Date.now();
            if (!courseData.terms[termId]) {
                courseData.terms[termId] = { timestamp: Date.now(), videos: {} };
            } else {
                courseData.terms[termId].timestamp = Date.now();
            }

            const videoId = Object.keys(newVideoEntry)[0];
            courseData.terms[termId].videos[videoId] = {
                status: newVideoEntry[videoId],
                timestamp: Date.now()
            };
            this.log(`[边界缓存] 已更新学期 ${termId} 中视频ID ${videoId} 的状态为 "${newVideoEntry[videoId]}"`, 'success');

            localStorage.setItem(key, JSON.stringify(courseData));
        },

        clearBoundaryCaches: function() { let c = 0; Object.keys(localStorage).forEach(k => { if (k.startsWith(this.BOUNDARY_CACHE_PREFIX)) { localStorage.removeItem(k); c++; } }); return c; },
        releaseBoundaryMemory: function() { this.navigationSessionId = null; this.isCheckingBoundary = false; const i = document.querySelector('iframe[src*="sm=1"]'); if (i) { i.src = 'about:blank'; setTimeout(() => i.remove(), 0); } this.log('内存管理: 当前边界检测任务已重置。缓存数据不受影响。', 'success'); alert('边界检测内存已释放!\n\n- 当前页面的后台检测任务已终止。\n- 本地存储的边界缓存数据未被删除。'); },

        checkIdValidityWithIframe: function(videoId, sessionId) {
            return new Promise((resolve) => {
                let iframe = document.createElement('iframe');
                let popupObserver = null, muteObserver = null;
                let timeout = setTimeout(() => cleanupAndResolve(true), 12000);
                iframe.sandbox = 'allow-same-origin allow-scripts';
                iframe.style.display = 'none';
                const cleanupAndResolve = (isValid) => {
                    if (sessionId !== this.navigationSessionId) { resolve(null); return; }
                    clearTimeout(timeout);
                    if (popupObserver) popupObserver.disconnect();
                    if (muteObserver) muteObserver.disconnect();
                    if (iframe) { iframe.onload = null; iframe.src = 'about:blank'; setTimeout(() => iframe?.parentElement?.removeChild(iframe), 0); }
                    resolve({ id: videoId, status: isValid ? 'valid' : 'invalid' });
                };
                iframe.onload = () => {
                    try {
                        const iframeWin = iframe.contentWindow;
                        if (!iframeWin) throw new Error("iframe contentWindow is not accessible.");
                        iframeWin.document.querySelectorAll('video').forEach(v => { if (!v.muted) v.muted = true; });
                        muteObserver = new MutationObserver((mutations) => { mutations.forEach(mutation => mutation.addedNodes.forEach(node => { if (node.nodeType === 1) { if (node.tagName === 'VIDEO' && !node.muted) node.muted = true; else node.querySelectorAll?.('video').forEach(v => { if (!v.muted) v.muted = true; }); } })); });
                        muteObserver.observe(iframeWin.document.documentElement, { childList: true, subtree: true });
                        popupObserver = new MutationObserver(() => { if (iframeWin.document.querySelector('.m-dialog .cnt')?.textContent.includes('该课时数据不存在')) { cleanupAndResolve(false); } });
                        popupObserver.observe(iframeWin.document.documentElement, { childList: true, subtree: true });
                    } catch (e) { cleanupAndResolve(true); }
                };
                document.body.appendChild(iframe);
                iframe.src = `${location.pathname}?tid=${this.termId}#/learn/content?type=detail&id=${videoId}&sm=1`;
            });
        },

        updateNavButtonStates: function() {
            const idMatch = location.hash.match(/id=(\d+)/);
            const prevBtn = document.getElementById('rh-prev-btn');
            const nextBtn = document.getElementById('rh-next-btn');
            if (!this.courseId || !this.termId || !idMatch || !prevBtn || !nextBtn) return;
            const cache = this.getBoundaryCache(this.courseId, this.termId);
            const prevId = (parseInt(idMatch[1], 10) - 1).toString();
            const nextId = (parseInt(idMatch[1], 10) + 1).toString();
            const isPrevDisabled = cache[prevId] === 'invalid';
            const isNextDisabled = cache[nextId] === 'invalid';
            if (prevBtn.disabled !== isPrevDisabled) this.log(`[UI更新] “上一个”按钮状态 -> ${isPrevDisabled ? '禁用' : '启用'}`, 'info');
            prevBtn.disabled = isPrevDisabled; prevBtn.title = isPrevDisabled ? '已尝试过,这很可能是第一节' : '切换上一个视频 (B)';
            if (nextBtn.disabled !== isNextDisabled) this.log(`[UI更新] “下一个”按钮状态 -> ${isNextDisabled ? '禁用' : '启用'}`, 'info');
            nextBtn.disabled = isNextDisabled; nextBtn.title = isNextDisabled ? '已尝试过,这很可能是最后一节' : '切换下一个视频 (N)';
        },

        injectNavigationButtons: function(autoplayCheckbox) {
            if (document.getElementById('rh-nav-container') || !autoplayCheckbox) return;
            this.log('加载序列(3/3): [导航模块] 开始注入UI...', 'info');
            const createButton = (text, id, clickHandler) => { const button = document.createElement('button'); button.textContent = text; button.id = id; button.className = 'rh-nav-button'; button.addEventListener('click', clickHandler); return button; };
            const container = document.createElement('div'); container.id = 'rh-nav-container';
            container.appendChild(createButton('◀ 上一个', 'rh-prev-btn', () => this.navigate(-1)));
            container.appendChild(createButton('下一个 ▶', 'rh-next-btn', () => this.navigate(1)));
            const autoplayContainer = autoplayCheckbox.parentElement;
            if (autoplayContainer?.parentElement) {
                autoplayContainer.parentElement.style.display = 'flex'; autoplayContainer.parentElement.style.alignItems = 'center';
                autoplayContainer.insertAdjacentElement('afterend', container);
                this.log('加载序列(3/3): [导航模块] UI注入成功。', 'success');
                this.updateNavButtonStates();
            } else {
                 this.log('加载序列(3/3): [导航模块] 未能找到合适的UI注入点。', 'error');
            }
        },

        updateSliderFill: function(slider) { const p = ((slider.value - slider.min) / (slider.max - slider.min)) * 100; sliderStyle.innerHTML = ` #rh-gain-slider::-webkit-slider-runnable-track { background: linear-gradient(to right, #27cc7e, #27cc7e ${p}%, #FFFFFF ${p}%) !important; } #rh-gain-slider::-moz-range-track { background: linear-gradient(to right, #27cc7e, #27cc7e ${p}%, #FFFFFF ${p}%) !important; } `;},

        injectTopUI: function() {
            if (document.getElementById("rh-top-container")) return;
            const titleBar = document.querySelector('.titleBar'); if (!titleBar) return;
            const container = document.createElement('div'); container.id = 'rh-top-container'; const currentGain = parseFloat(localStorage.getItem(this.GAIN_STORAGE_KEY)) || 6.0; container.innerHTML = `<label for="rh-gain-slider">音量增益:</label><input type="range" id="rh-gain-slider" min="0" max="10" step="1" value="${Math.min(currentGain, 10)}"><input type="number" id="rh-gain-input" min="0" value="${currentGain}">`; titleBar.appendChild(container); const slider = document.getElementById('rh-gain-slider'); const input = document.getElementById('rh-gain-input');
            const updateGainHandler = (gain) => {
                if (this.namespace.audioControl) this.namespace.audioControl.amplify(gain);
                localStorage.setItem(this.GAIN_STORAGE_KEY, gain);
                this.updateSliderFill(slider);
                this.log(`[音量增益] 已调节至 ${gain}x。`);
            };
            slider.addEventListener('input', () => { input.value = slider.value; updateGainHandler(parseFloat(slider.value)); });
            input.addEventListener('change', () => { let gain = parseInt(input.value, 10); if (isNaN(gain) || gain < 0) gain = 0; input.value = gain; if (gain <= 10) slider.value = gain; updateGainHandler(gain); });
            this.updateSliderFill(slider);
        },

        amplifyMedia: function(mediaElem, multiplier) { const ctx = new(window.AudioContext || window.webkitAudioContext)(); const src = ctx.createMediaElementSource(mediaElem); const gain = ctx.createGain(); gain.gain.value = multiplier; src.connect(gain).connect(ctx.destination); this.namespace.audioControl = { amplify: (v) => { gain.gain.value = v; } }; mediaElem.addEventListener('play', () => { if (ctx.state === 'suspended') ctx.resume(); }, { once: true }); },

        initErrorDialogObserver: function() {
            if (this.errorObserver) this.errorObserver.disconnect(); this.errorObserver = new MutationObserver(mutations => { for (const m of mutations) { for (const n of m.addedNodes) { if (n.nodeType === 1 && n.querySelector?.('.m-dialog .cnt')?.textContent.includes('该课时数据不存在')) { this.errorObserver.disconnect(); this.errorObserver = null; const fIdMatch = location.hash.match(/id=(\d+)/); if (fIdMatch && this.courseId && this.termId) { const fId = fIdMatch[1]; this.setBoundaryCache(this.courseId, this.termId, {[fId]: 'invalid'}); this.log(`[错误哨兵] 检测到“数据不存在”,已将无效ID ${fId} 记录到缓存。`, 'success'); this.updateNavButtonStates(); } setTimeout(() => { this.log('[错误哨兵] 自动关闭错误弹窗并执行返回操作。', 'info'); document.querySelector('.m-dialog .j-left, .m-winmark .zcls')?.click(); this.navigate(-this.lastNavigationDirection, true); }, 150); return; } } } }); this.errorObserver.observe(document.body, { childList: true, subtree: true });
        },

        async proactiveBoundaryCheck() {
            if (this.isCheckingBoundary) return;
            this.isCheckingBoundary = true;
            const sessionId = this.navigationSessionId;
            const idMatch = location.hash.match(/id=(\d+)/);
            if (!this.courseId || !this.termId || !idMatch) { this.isCheckingBoundary = false; return; }
            this.log('加载序列(2/C): [后台边界检测] 任务启动...', 'info');

            const currentId = parseInt(idMatch[1], 10);
            const idsToCheck = [ { id: (currentId - 1).toString(), label: "上一个" }, { id: (currentId + 1).toString(), label: "下一个" } ];
            const cache = this.getBoundaryCache(this.courseId, this.termId);
            const promisesToRun = [];

            for (const item of idsToCheck) {
                if (cache[item.id] === undefined) {
                    this.log(`[边界检测] 检查 ${item.label} (ID: ${item.id}): 无缓存,启动后台iframe检测...`, 'info');
                    promisesToRun.push(this.checkIdValidityWithIframe(item.id, sessionId));
                } else {
                    this.log(`[边界检测] 检查 ${item.label} (ID: ${item.id}): 命中缓存,状态为 "${cache[item.id]}"`, 'success');
                }
            }

            if (promisesToRun.length > 0) {
                // BUG FIX: Await the promises first, *then* filter the results.
                const results = await Promise.all(promisesToRun);
                for(const result of results) {
                    if (result) { // Filter out nulls from aborted/mismatched sessions
                        this.log(`[边界检测] 后台检测结果: ID ${result.id} 为 ${result.status}。`, 'success');
                        this.setBoundaryCache(this.courseId, this.termId, { [result.id]: result.status });
                    }
                }
            }
            this.isCheckingBoundary = false;
            this.log('加载序列(2/C): [后台边界检测] 任务完成。', 'success');
        },

        waitForVideoAndInjectGain: function() { return new Promise(resolve => { this.log('加载序列(2/A): [音量增益模块] 启动,等待播放器...', 'info'); let observer; const timeout = setTimeout(() => { if(observer) observer.disconnect(); this.log('加载序列(2/A): [音量增益模块] 检测超时(15s),注入失败。', 'error'); resolve(false); }, 15000); observer = new MutationObserver(() => { const video = document.querySelector('video'); const titleBar = document.querySelector('.titleBar'); if (video && titleBar) { this.log('加载序列(2/A): [音量增益模块] 检测成功,注入UI。', 'success'); this.setupGainEnhancer(video); this.injectTopUI(); clearTimeout(timeout); observer.disconnect(); resolve(true); } }); observer.observe(document.body, { childList: true, subtree: true }); }); },
        waitForCheckboxAndSyncState: function() { return new Promise(resolve => { this.log('加载序列(2/B): [自动播放状态同步] 启动,等待复选框...', 'info'); let observer; const timeout = setTimeout(() => { if(observer) observer.disconnect(); this.log('加载序列(2/B): [自动播放状态同步] 检测超时(15s),同步失败。', 'error'); resolve(null); }, 15000); observer = new MutationObserver(() => { const checkbox = document.querySelector('input.j-autoNext'); if (checkbox) { this.log('加载序列(2/B): [自动播放状态同步] 检测成功,同步状态。', 'success'); if (!checkbox.dataset.rhManaged) { checkbox.dataset.rhManaged = 'true'; const savedState = localStorage.getItem(this.AUTOPLAY_STORAGE_KEY); let desiredState = (savedState === null) ? false : (savedState === 'true'); this.log(`[自动播放] 读取到已保存状态为“${desiredState}”。`, 'info'); if (checkbox.checked !== desiredState) { checkbox.click(); this.log(`[自动播放] 已将页面选项同步为“${desiredState}”。`, 'success'); } checkbox.addEventListener('change', () => { localStorage.setItem(this.AUTOPLAY_STORAGE_KEY, checkbox.checked.toString()); this.log(`[自动播放] 用户更改状态为“${checkbox.checked}”,已保存。`, 'info'); }); } clearTimeout(timeout); observer.disconnect(); resolve(checkbox); } }); observer.observe(document.body, { childList: true, subtree: true }); }); },

        async runOnPageChange() {
            this.activeObservers.forEach(obs => obs.disconnect());
            this.activeObservers = [];
            if (this.errorObserver) this.errorObserver.disconnect();
            this.cleanupUI();

            this.navigationSessionId = Date.now();
            this.courseId = this.getCourseId();
            this.termId = this.getTermId();
            const idMatch = location.hash.match(/id=(\d+)/);

            if (!idMatch || !this.courseId || !this.termId) {
                this.log(`核心功能模块未加载 (当前非视频内容页,或缺少学期ID)。课程ID: ${this.courseId}, 学期ID: ${this.termId}`, 'warn');
                this.namespace.currentVideo = null;
                return;
            }

            this.log(`加载序列(1/3): 进入视频页 (课程ID: ${this.courseId}, 学期ID: ${this.termId}, 视频ID: ${idMatch[1]}),启动并行加载...`, 'success');
            const cache = this.getBoundaryCache(this.courseId, this.termId);
            if (cache[idMatch[1]] !== 'valid') {
                this.setBoundaryCache(this.courseId, this.termId, {[idMatch[1]]: 'valid'});
            }

            this.namespace = {};
            this.initErrorDialogObserver();

            const gainPromise = this.waitForVideoAndInjectGain();
            const autoplaySyncPromise = this.waitForCheckboxAndSyncState();
            // Boundary promise must be handled carefully due to async nature
            const boundaryPromise = this.proactiveBoundaryCheck();

            // Handle UI injection after their primary dependencies are met
            Promise.all([gainPromise, autoplaySyncPromise, boundaryPromise.catch(e => {
                this.log(`[边界检测] 模块出现严重错误: ${e}`, 'error');
                // Allow other things to proceed even if boundary check fails
                return null;
            })]).then(([gainSuccess, checkboxElement]) => {
                this.log('加载序列: 并行任务结束,进行功能状态总结。', 'info');
                if (!gainSuccess) {
                    this.log('[音量增益模块] 因初始化超时或失败,功能已禁用。', 'warn');
                }
                if (checkboxElement) {
                    this.injectNavigationButtons(checkboxElement);
                } else {
                    this.log('[导航模块] 因自动播放复选框未找到或超时,功能已禁用。', 'warn');
                }
            });
        },

        cleanupUI() { document.getElementById('rh-top-container')?.remove(); document.getElementById('rh-nav-container')?.remove(); },
        setupGainEnhancer(video) { if (this.namespace.currentVideo === video) return; this.namespace.currentVideo = video; const initialGain = parseFloat(localStorage.getItem(this.GAIN_STORAGE_KEY)) || 6.0; this.amplifyMedia(video, initialGain); this.log('[音量增益] 成功附加到新的视频元素。', 'success'); },
        navigate(offset, isUndo = false) { this.lastNavigationDirection = isUndo ? 0 : offset; const idMatch = location.hash.match(/id=(\d+)/); if (idMatch?.[1]) { const newId = parseInt(idMatch[1], 10) + offset; this.log(`[导航] 切换到 ${offset > 0 ? '下一个' : '上一个'} 视频 (ID: ${newId})`, 'info'); location.hash = `#/learn/content?type=detail&id=${newId}&sm=1`; } },
    };

    function initSelectionAndContextMenuLift() { const e = ['contextmenu', 'selectstart', 'dragstart', 'copy']; const t = e => e.stopPropagation(); document.oncontextmenu = document.onselectstart = document.ondragstart = document.oncopy = null; e.forEach(e => document.addEventListener(e, t, true)); enhancerModule.log('加载序列(0/3): 页面限制解除。', 'success'); }

    function initKeyboardControl(module) {
        document.addEventListener('keydown', function(event) {
            const activeElement = document.activeElement;
            const isTyping = activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.isContentEditable);

            if (isTyping) {
                if (['Space', 'ArrowUp', 'ArrowDown', 'KeyB', 'KeyN'].includes(event.code)) {
                    module.log(`[快捷键] 输入状态,忽略“${event.code}”操作。`, 'warn');
                }
                return;
            }

            const video = module.namespace.currentVideo || document.querySelector('video');
            switch (event.code) {
                case 'Space': if (video) { event.preventDefault(); const isPaused = video.paused; if (isPaused) video.play(); else video.pause(); module.log(`[快捷键] “空格” -> ${isPaused ? '播放' : '暂停'}`); } break;
                case 'ArrowUp': if (video) { event.preventDefault(); video.volume = Math.min(1, video.volume + 0.1); module.log(`[快捷键] “↑” -> 音量增加`); } break;
                case 'ArrowDown': if (video) { event.preventDefault(); video.volume = Math.max(0, video.volume - 0.1); module.log(`[快捷键] “↓” -> 音量降低`); } break;
                case 'KeyB': { event.preventDefault(); const btn = document.getElementById('rh-prev-btn'); if (btn && !btn.disabled) { module.log('[快捷键] “B” -> 触发“上一个”', 'info'); btn.click(); } else { module.log('[快捷键] “B” -> “上一个”不可用', 'warn'); } break; }
                case 'KeyN': { event.preventDefault(); const btn = document.getElementById('rh-next-btn'); if (btn && !btn.disabled) { module.log('[快捷键] “N” -> 触发“下一个”', 'info'); btn.click(); } else { module.log('[快捷键] “N” -> “下一个”不可用', 'warn'); } break; }
            }
        }, true);
        module.log('加载序列(0/3): 键盘快捷键模块已加载。', 'success');
    }

    function main() {
        if (window.self !== window.top) { enhancerModule.log('主程序: 检测到脚本运行在 iFrame 中,功能已禁用。', 'warn'); return; }
        enhancerModule.log('主程序: 脚本运行在顶层窗口,开始初始化...', 'success');
        GM_registerMenuCommand("🗑️ 清除视频边界缓存", () => { const c = enhancerModule.clearBoundaryCaches(); enhancerModule.log(`[菜单命令] 执行“清除视频边界缓存”,清除了 ${c} 个课程的缓存。`, 'success'); alert(`所有课程的边界缓存已清除(共 ${c} 个)。\n下次进入视频页将重新检测。`); });
        GM_registerMenuCommand("♻️ 释放边界检测内存", enhancerModule.releaseBoundaryMemory.bind(enhancerModule));
        initSelectionAndContextMenuLift();
        initKeyboardControl(enhancerModule);
        window.addEventListener('hashchange', () => enhancerModule.runOnPageChange());
        if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => enhancerModule.runOnPageChange()); } else { enhancerModule.runOnPageChange(); }
    }

    main();

})();