From dfe95a6f5a45d5163ad601efab49cafd06c69d81 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:27:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=93=E6=B3=A8=E6=A8=A1=E5=BC=8F=EF=BC=9A?= =?UTF-8?q?=E6=8D=A2=E8=A7=86=E9=A2=91=E5=92=8C=E8=AF=BB=E5=8F=96=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=9A=84=E5=88=A4=E6=96=AD=E6=8A=BD=E6=88=90=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E5=B9=B6=E8=A1=A5=E8=A1=A8=E6=A0=BC=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isOtherClip、clipChangeAction、readFailureAction、clipFields / clipPage 不碰 DOM,content.selftest.js 逐行覆盖;等待和世代号的代码不动,行为不变。 --- extension/content.js | 84 +++++++++++++++++++++++------------ extension/content.selftest.js | 41 +++++++++++++++++ 2 files changed, 97 insertions(+), 28 deletions(-) diff --git a/extension/content.js b/extension/content.js index 7b6bd93..e62da12 100644 --- a/extension/content.js +++ b/extension/content.js @@ -858,11 +858,16 @@ async function readPlayerChange() { // Unread (no answer from the background) is no change: the next read asks the player again. const ref = await readPlayerRef().catch(() => null); // Compared again: the URL watcher may have reset the state while the player answered. - if (watching() && ref && (ref.id !== state.videoId || (state.cid && ref.part.cid !== state.cid))) { + if (watching() && ref && isOtherClip(ref, state)) { videoChanged(); } } +// Another video, or another part once the read knows its cid: before that, only the id can tell. +function isOtherClip(ref, clip) { + return ref.id !== clip.videoId || Boolean(clip.cid && ref.part.cid !== clip.cid); +} + function videoChanged() { const nextUrl = location.href; state.currentClipSignature = computeCurrentClipSignature(); @@ -880,18 +885,22 @@ function videoChanged() { // Another part of the same video keeps its title until the info is read. The page's own title is not used: at this // point it may still be the last video's (B站 updates it later, YouTube's og:title not at all). if (MdgSites.parseRef(nextUrl)?.id === prev.id) state.title = prev.title; - const shouldEnterReaderMode = hasReaderParam(nextUrl); - if (!state.readingViewOpen && shouldEnterReaderMode) { + const action = clipChangeAction(state.readingViewOpen, hasReaderParam(nextUrl)); + if (action === "enter") { markReaderMode(); startReaderMode(); - return; - } - if (state.readingViewOpen || shouldEnterReaderMode) { + } else if (action === "refresh") { setReadingNotice(""); // Not waiting for the player: the video's info and duration come from the site (sites.js fetchMeta). refreshClip().catch(readerReadFailed); } - // Out of 专注模式 nothing is read here: the next entry that asks (popup, side panel, player ✦) reads it via settledClip. +} + +// 专注模式 open reads the new clip, the reader URL param opens it. Otherwise nothing is read here: the next entry that +// asks (popup, side panel, player ✦) reads it via settledClip. +function clipChangeAction(readingViewOpen, readerParam) { + if (readingViewOpen) return "refresh"; + return readerParam ? "enter" : "none"; } function resetClipState() { @@ -949,6 +958,7 @@ function buildSidepanelContext() { subtitleMarkdown = ""; logWarn("[MDG] sidepanel-get-context: buildMarkdown failed", e); } + const { pageIndex, pageCount } = clipPage(state); return { ok: true, payload: { @@ -961,8 +971,8 @@ function buildSidepanelContext() { cid: state.cid || "", aid: state.aid || "", description: state.description || "", - pageIndex: Number(state.pageIndex) > 0 ? Number(state.pageIndex) : 1, - pageCount: Number(state.pageCount) > 0 ? Number(state.pageCount) : 0, + pageIndex, + pageCount, pageTitle: state.pageTitle || "", subtitleMarkdown, subtitleFailure: state.subtitleFailure, @@ -1061,24 +1071,12 @@ async function runRefreshClip() { run.check(); logInfo("[MDG] raw meta data", { meta }); - const dom = site.readDom(document); state.meta = meta; metaLoaded = true; - state.aid = meta.aid || ""; - state.title = meta.title || dom.title; - state.author = meta.author || dom.author; - state.authorUrl = meta.authorUrl || ""; - state.cover = meta.cover || ""; - state.videoTags = Array.isArray(meta.tags) ? meta.tags : []; - state.uploadDate = meta.uploadDate || dom.uploadDate; - state.description = meta.description || dom.description; - state.pageCount = Number(meta.pageCount) || 0; + Object.assign(state, clipFields(meta, site.readDom(document))); state.currentClipSignature = clipSignature; - state.pageIndex = Number(meta.pageIndex) > 0 ? Number(meta.pageIndex) : 1; - state.pageTitle = meta.pageTitle || ""; - state.cid = meta.cid || ""; // Without a duration the subtitle is not checked against one, as on every other entry (sites.js). - state.videoDuration = Number(meta.duration) > 0 ? Number(meta.duration) : readRuntimeVideoDuration(); + state.videoDuration ||= readRuntimeVideoDuration(); if (state.readingViewOpen) { renderReadingView(); } @@ -1118,14 +1116,12 @@ async function runRefreshClip() { await showSubtitle(found, run); setStatus(state.subtitleWarning + readDoneText(), Boolean(state.commentsFailure)); } catch (error) { - // A newer run (the video changed) owns the state now: an old video's late failure must not reset or mark it. - if (MdgCommon.isStale(error) || !run.alive) { + const action = readFailureAction(error, run.alive, metaLoaded); + if (action === "ignore") { return; } const reason = failureReason(error); - // Only the subtitle step failed: keep the video info and degrade to the no-subtitle state. A limit is no subtitle - // failure: it fails the read like the side panel and 逐个视频笔记 do, so no note says it and 重试 reads it all again. - if (metaLoaded && !MdgSites.isLimit(error)) { + if (action === "degrade") { try { await showNoSubtitleState(run, { failure: reason }); } catch (degradeError) { @@ -1147,6 +1143,38 @@ async function runRefreshClip() { } } +// The video's info a read keeps: the site's first, the page's where the site has none. +function clipFields(meta, dom) { + return { + aid: meta.aid || "", + title: meta.title || dom.title, + author: meta.author || dom.author, + authorUrl: meta.authorUrl || "", + cover: meta.cover || "", + videoTags: Array.isArray(meta.tags) ? meta.tags : [], + uploadDate: meta.uploadDate || dom.uploadDate, + description: meta.description || dom.description, + ...clipPage(meta), + pageTitle: meta.pageTitle || "", + cid: meta.cid || "", + videoDuration: Number(meta.duration) > 0 ? Number(meta.duration) : 0 + }; +} + +// Parts count from 1; a count of 0 is unknown. +function clipPage({ pageIndex, pageCount }) { + return { pageIndex: Number(pageIndex) > 0 ? Number(pageIndex) : 1, pageCount: Number(pageCount) > 0 ? Number(pageCount) : 0 }; +} + +// What a failed read does. A newer run (the video changed) owns the state now: an old video's late failure must not +// reset or mark it ("ignore"). Only the subtitle step failed: keep the video info and degrade to the no-subtitle state. +// A limit is no subtitle failure: it fails the read like the side panel and 逐个视频笔记 do, so no note says it and +// 重试 reads it all again ("fail"). +function readFailureAction(error, alive, metaLoaded) { + if (MdgCommon.isStale(error) || !alive) return "ignore"; + return metaLoaded && !MdgSites.isLimit(error) ? "degrade" : "fail"; +} + // Reasons arrive with or without a trailing full stop; strip it so the sentences after them join cleanly. function failureReason(error) { return MdgSites.trimReason(fetchErrorText(error)); diff --git a/extension/content.selftest.js b/extension/content.selftest.js index 6b3b32e..25a2568 100644 --- a/extension/content.selftest.js +++ b/extension/content.selftest.js @@ -299,6 +299,47 @@ const settle = async () => { t.location.href = saved.href; } + // What the player's clip, a clip change and a failed read decide. + { + const ref = (id, cid) => ({ id, part: { cid } }); + for (const [player, read, other, why] of [ + [ref("BV1", "7"), { videoId: "BV1", cid: "7" }, false, "same part"], + [ref("BV1", "8"), { videoId: "BV1", cid: "7" }, true, "another part"], + [ref("BV2", "7"), { videoId: "BV1", cid: "7" }, true, "another video"], + [ref("BV1", "8"), { videoId: "BV1", cid: "" }, false, "cid not read yet: only the id tells"], + [ref("BV2", "8"), { videoId: "BV1", cid: "" }, true, "cid not read yet, another video"] + ]) { + assert.strictEqual(t.isOtherClip(player, read), other, why); + } + for (const [open, param, action] of [[true, true, "refresh"], [true, false, "refresh"], [false, true, "enter"], [false, false, "none"]]) { + assert.strictEqual(t.clipChangeAction(open, param), action, `open ${open}, param ${param}`); + } + const stale = { code: "STALE_RUN" }; + const limit = { code: t.MdgErrors.CODE.THROTTLED }; + const other = new Error("x"); + for (const [error, alive, metaLoaded, action] of [ + [stale, true, true, "ignore"], + [other, false, true, "ignore"], + [limit, false, false, "ignore"], + [other, true, true, "degrade"], + [limit, true, true, "fail"], + [{ status: 429 }, true, true, "fail"], + [other, true, false, "fail"] + ]) { + assert.strictEqual(t.readFailureAction(error, alive, metaLoaded), action, JSON.stringify([error, alive, metaLoaded])); + } + const dom = { title: "页标题", author: "页作者", uploadDate: "2026-01-01", description: "页简介" }; + assert.deepStrictEqual( + JSON.parse(JSON.stringify(t.clipFields({ title: "T", author: "A", aid: 3, cid: 9, tags: ["x"], pageIndex: "2", pageCount: 3, duration: "61" }, dom))), + { aid: 3, title: "T", author: "A", authorUrl: "", cover: "", videoTags: ["x"], uploadDate: "2026-01-01", description: "页简介", pageIndex: 2, pageCount: 3, pageTitle: "", cid: 9, videoDuration: 61 } + ); + assert.deepStrictEqual( + JSON.parse(JSON.stringify(t.clipFields({ tags: "x", pageIndex: 0, pageCount: -1, duration: 0 }, dom))), + { aid: "", title: "页标题", author: "页作者", authorUrl: "", cover: "", videoTags: [], uploadDate: "2026-01-01", description: "页简介", pageIndex: 1, pageCount: 0, pageTitle: "", cid: "", videoDuration: 0 }, + "the page's info where the site has none, positions clamped, no duration left to the player" + ); + } + // F8: the YouTube PO token capture toggles the page's captions button only for the video of its run: ended before it // starts, the button is left alone; ended between its two rounds, the second round is not made. {