Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 56 additions & 28 deletions extension/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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() {
Expand Down Expand Up @@ -949,6 +958,7 @@ function buildSidepanelContext() {
subtitleMarkdown = "";
logWarn("[MDG] sidepanel-get-context: buildMarkdown failed", e);
}
const { pageIndex, pageCount } = clipPage(state);
return {
ok: true,
payload: {
Expand All @@ -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,
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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));
Expand Down
41 changes: 41 additions & 0 deletions extension/content.selftest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
{
Expand Down
Loading