diff --git a/qingshuxuetang_auto_play.user.js b/qingshuxuetang_auto_play.user.js index b7f4cdf..97fc9cf 100644 --- a/qingshuxuetang_auto_play.user.js +++ b/qingshuxuetang_auto_play.user.js @@ -17,8 +17,112 @@ const MIN_STUDY_SECONDS = 5 * 60; const VIDEO_SELECTOR = "#vjs_video_3_html5_api"; const TEXT_SELECTOR = "#easyXDM_default2872_provider"; - console.log("插件加载完成,等待界面渲染"); + const TOAST_CONTAINER_ID = "qingshu-auto-toast-container"; + const TOAST_STYLE_ID = "qingshu-auto-toast-style"; + const TOAST_DEFAULT_DURATION = 4000; + // 创建右下角 toast 容器和样式。 + // 每次通知都会创建一个新的 toast,所以多个通知会一起堆叠显示。 + const ensureToastContainer = () => { + let container = document.querySelector("#" + TOAST_CONTAINER_ID); + + if (!document.querySelector("#" + TOAST_STYLE_ID)) { + const style = document.createElement("style"); + style.id = TOAST_STYLE_ID; + style.textContent = ` + #${TOAST_CONTAINER_ID} { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 2147483647; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 10px; + max-width: min(360px, calc(100vw - 36px)); + pointer-events: none; + } + + #${TOAST_CONTAINER_ID} .qingshu-toast { + box-sizing: border-box; + min-width: 220px; + max-width: 100%; + padding: 10px 12px; + border-left: 4px solid #3b82f6; + border-radius: 8px; + background: rgba(17, 24, 39, 0.94); + color: #fff; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.24); + font-size: 13px; + line-height: 1.45; + opacity: 0; + transform: translateY(10px); + transition: opacity 180ms ease, transform 180ms ease; + pointer-events: auto; + word-break: break-word; + } + + #${TOAST_CONTAINER_ID} .qingshu-toast.show { + opacity: 1; + transform: translateY(0); + } + + #${TOAST_CONTAINER_ID} .qingshu-toast.success { + border-left-color: #22c55e; + } + + #${TOAST_CONTAINER_ID} .qingshu-toast.warning { + border-left-color: #f59e0b; + } + + #${TOAST_CONTAINER_ID} .qingshu-toast.error { + border-left-color: #ef4444; + } + `; + (document.head || document.documentElement).appendChild(style); + } + + if (!container) { + container = document.createElement("div"); + container.id = TOAST_CONTAINER_ID; + (document.body || document.documentElement).appendChild(container); + } + + return container; + }; + + // 显示一条 toast,同时保留 console 输出。 + // type 可用 info、success、warning、error;duration 是自动隐藏时间。 + const notify = (message, type = "info", duration = TOAST_DEFAULT_DURATION) => { + const log = type === "error" ? console.error : console.log; + log(message); + + const container = ensureToastContainer(); + const toast = document.createElement("div"); + toast.className = "qingshu-toast " + type; + toast.innerText = message; + container.appendChild(toast); + + requestAnimationFrame(() => { + toast.classList.add("show"); + }); + + const removeToast = () => { + toast.classList.remove("show"); + setTimeout(() => { + toast.remove(); + }, 180); + }; + + toast.addEventListener("click", removeToast); + setTimeout(removeToast, duration); + }; + + notify("插件加载完成,等待界面渲染", "info"); + + // 等待某个 DOM 条件成立。 + // 页面里的课程列表、视频、文本课件经常是后加载的,所以不用固定延迟, + // 而是监听 DOM 变化,直到 getResult() 返回有效结果。 const waitForCondition = (getResult) => { const result = getResult(); @@ -43,6 +147,8 @@ }); }; + // 把“共4分钟49秒”“共1小时2分钟3秒”这类文字换算成秒数, + // 方便判断是否已经学满 5 分钟。 const parseStudySeconds = (text) => { const hourMatch = text.match(/(\d+)\s*小时/); const minuteMatch = text.match(/(\d+)\s*分钟/); @@ -55,6 +161,10 @@ ); }; + // 判断一个课程节点是否需要进入: + // 1. 没有 study_being,说明还没学过,需要进入; + // 2. 学过但累计时间低于 5 分钟,也需要再次进入; + // 3. 学习时间达到 5 分钟及以上,就跳过。 const shouldEnterCourseware = (item) => { const mark = item.querySelector(".study_being"); @@ -66,6 +176,8 @@ return studiedSeconds < MIN_STUDY_SECONDS; }; + // 返回课程列表页。 + // 优先点击页面自带的返回按钮;如果没找到,就用浏览器历史记录兜底。 const returnToStudyPage = () => { const backLink = document.querySelector(".back-link"); @@ -77,6 +189,8 @@ history.back(); }; + // 播放页可能是视频课件,也可能是文本课件。 + // 这里会一直等到其中一种课件元素真正出现在页面上。 const waitForCoursewareElement = () => waitForCondition( () => @@ -85,10 +199,12 @@ ); const onPageLoad = async () => { - console.log("页面加载完成,开始执行脚本"); + notify("页面加载完成,开始执行脚本", "info"); if (url.includes("CourseStudy")) { - console.log("进入准备阶段"); + notify("进入准备阶段,等待课程列表", "info"); + + // 课程列表是异步渲染的,等到至少有一个课程节点后再开始遍历。 const items = await waitForCondition(() => { const list = document.querySelector("#list"); const items = list ? list.querySelectorAll("li > a.node") : []; @@ -96,60 +212,72 @@ return items.length > 0 ? items : null; }); - console.log("获取到课程数量:", items.length); + notify("获取到课程数量:" + items.length, "success"); + // 从上到下找到第一个“没学过”或“学习不足5分钟”的课件并进入。 for (const item of items) { if (shouldEnterCourseware(item)) { const mark = item.querySelector(".study_being"); const studiedSeconds = mark ? parseStudySeconds(mark.innerText) : 0; - console.log( + notify( "点击未完成学习时长的视频:" + item.querySelector(".title").innerText + ",已学习:" + studiedSeconds + - "秒" + "秒", + "success", + 6000 ); item.click(); return; } } - console.log("所有视频均已播放完毕,结束脚本"); + notify("所有视频均已播放完毕,结束脚本", "success", 6000); } else if (url.includes("CourseShow")) { - console.log("进入学习阶段"); - // 学习阶段 + notify("进入学习阶段,等待课件加载", "info"); + + // 等到播放页真正渲染出视频或文本课件,再决定如何处理。 const coursewareElement = await waitForCoursewareElement(); + // 文本课件没有 video 标签,不需要播放;停留 5 分钟后返回列表页。 if (coursewareElement.matches(TEXT_SELECTOR)) { - console.log("检测到文本课件,等待5分钟后返回准备页面"); + notify("检测到文本课件,等待5分钟后返回准备页面", "warning", 6000); setTimeout(() => { - console.log("文本课件等待完成,返回准备页面"); + notify("文本课件等待完成,返回准备页面", "success"); returnToStudyPage(); }, MIN_STUDY_SECONDS * 1000); return; } + // 视频课件需要周期性检查播放状态:启动播放、设置倍速、播完返回。 + let videoMissingNotified = false; const videoInterval = setInterval(() => { const video = document.querySelector(VIDEO_SELECTOR); + // 有些播放器会重建 video 元素,短暂找不到时继续等下一轮。 if (!video) { - console.log("视频元素暂时不可用,继续等待"); + if (!videoMissingNotified) { + notify("视频元素暂时不可用,继续等待", "warning"); + videoMissingNotified = true; + } return; } + videoMissingNotified = false; if (video.paused && video.currentTime === 0) { video.play(); - console.log("视频未播放,开始播放视频"); + notify("视频未播放,开始播放视频", "success"); return; } if (video.currentTime >= video.duration - 1) { - console.log("视频播放结束,返回准备页面"); + notify("视频播放结束,返回准备页面", "success"); clearInterval(videoInterval); returnToStudyPage(); } if (video.playbackRate === 1) { - console.log("将视频倍速设置为2倍速"); + notify("将视频倍速设置为2倍速", "success"); video.playbackRate = 2; }