diff --git a/extension/content.js b/extension/content.js index 7b6bd93..2f94b8a 100644 --- a/extension/content.js +++ b/extension/content.js @@ -337,6 +337,8 @@ const UNNAMED_SUBTITLE = "未命名字幕"; const READER_FOLLOW_IDLE_MS = 1000; // Between two tries to mount the player after focus mode opened without one. const PLAYER_RETRY_MS = 500; +// How long after navigation focus mode waits for the page to be hydrated (pageHydrating). +const HYDRATE_WAIT_MS = 10000; // Smaller than this is a thumbnail or a preview, not the player. const MIN_PLAYER = { width: 240, height: 120 }; // The appearance settings focus mode changes itself; a change to them does not touch the note. @@ -1621,6 +1623,10 @@ async function ensureReaderPlayerMounted({ retries = 1, delayMs = 100, forceLayo if (!isReaderMode()) { return false; } + if (pageHydrating()) { + await sleep(delayMs); + continue; + } const video = getRuntimeVideoElement(); const playerHost = findReaderPlayerHost(video); if (video && playerHost) { @@ -1706,6 +1712,14 @@ async function ensureReaderPlayerMounted({ retries = 1, delayMs = 100, forceLayo return false; } +// B站 hands its server-rendered page to the client only after load (Vue then drops data-server-rendered). Focus mode +// put into the page before that leaves the page's own part switching dead for the visit (列表、下一个、自动连播), so the +// mount waits; past HYDRATE_WAIT_MS it goes ahead, in case a page keeps the marker. +function pageHydrating() { + const marks = reader().hydrating || []; + return Boolean(marks.length && document.querySelector(sel(marks))) && performance.now() < HYDRATE_WAIT_MS; +} + function queueEnsureReaderPlayerMounted() { if (!state.readingViewOpen || state.readingPlayerMountTimer) { return; diff --git a/extension/content.selftest.js b/extension/content.selftest.js index 6b3b32e..02267f7 100644 --- a/extension/content.selftest.js +++ b/extension/content.selftest.js @@ -172,6 +172,7 @@ const settle = async () => { state.subtitleBody = []; state.subtitles = [{ id: "1", url: "u" }]; const body = gate("raw"); + const site = t.currentSite; t.currentSite = () => ({ fetchRaw: () => body.promise, parseSegments: () => [{ from: 0, to: 1, content: "旧视频" }] }); const switching = t.switchSubtitle("u"); await settle(); @@ -179,6 +180,7 @@ const settle = async () => { body.open(); await assert.rejects(switching, (e) => e.message === "视频已切换,请刷新网页后重试"); assert.deepStrictEqual(state.subtitleBody, [], "the new video keeps an empty body"); + t.currentSite = site; } // G4: the note's AI turns and triage extras read for the old video are dropped once it changed. @@ -759,6 +761,31 @@ const settle = async () => { Object.assign(t, keep); } + // V8: on a B站 page not hydrated yet (data-server-rendered still there) the mount puts nothing into the page: that + // left B站's own part switching dead. Past HYDRATE_WAIT_MS after navigation it mounts anyway. + { + const names = ["getRuntimeVideoElement", "findReaderPlayerHost", "dismissReaderMiniPlayer", "bindReadingViewVideo", "moveReadingMainInline", "normalizeReaderPlayerContainer", "clearNativeReaderFloatingStyles", "hasNativeReaderPlayerLayoutIssue", "bindReaderPlayerControlsHover", "bindReaderLayout", "layoutReaderPlayerHost", "cleanupReaderPlayerHostNode", "setReaderPlayerControlsVisible", "ensureReaderPlayerControlsRecovered", "queueEnsureReaderPlayerControlsRecovered"]; + const keep = Object.fromEntries(names.map((k) => [k, t[k]])); + const [query, perf] = [t.document.querySelector, t.performance]; + t.document.querySelector = (s) => (s === "[data-server-rendered]" ? {} : null); + for (const [name, now, mounted, moved] of [["hydrating", 3000, false, 0], ["marker kept past the wait", 10001, true, 1]]) { + for (const f of names) t[f] = () => {}; + let moves = 0; + t.performance = { ...perf, now: () => now }; + t.getRuntimeVideoElement = () => ({ removeAttribute() {}, setAttribute() {}, closest: () => null }); + t.findReaderPlayerHost = () => ({ classList: { add() {} } }); + t.hasNativeReaderPlayerLayoutIssue = () => false; + t.moveReadingMainInline = () => moves++; + t.stepReader("mark"); + t.stepReader("enter"); + assert.deepStrictEqual([await real.ensureReaderPlayerMounted({ retries: 2, delayMs: 1 }), moves], [mounted, moved], name); + t.stepReader("close"); + } + Object.assign(t, keep, { performance: perf }); + t.document.querySelector = query; + state.readingPlayerHost = null; + } + // 跟随已停 shows after a manual scroll only while playing. Following stays off while the reader is away from the // playing line, however long; it resumes once the line is back in view and the reader has let go, or at 回到当前. { @@ -1523,7 +1550,6 @@ const settle = async () => { const was = { href: t.location.href, hostname: t.location.hostname, ...Object.fromEntries(["site", "videoId", "videoDuration", "subtitles"].map((k) => [k, state[k]])) }; t.location.href = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; t.location.hostname = "www.youtube.com"; - t.currentSite = () => t.MdgSites.matchSite(t.location.href); // the real one (a test above stubs it) Object.assign(state, { site: "youtube", videoId: "dQw4w9WgXcQ", videoDuration: 0 }); const url = "https://www.youtube.com/api/timedtext?v=dQw4w9WgXcQ&lang=en"; state.subtitles = [{ id: "en", lang: "en", url }]; diff --git a/extension/sites.js b/extension/sites.js index 4f3a235..5efe8ef 100644 --- a/extension/sites.js +++ b/extension/sites.js @@ -553,6 +553,8 @@ playerLayout: [".bpx-player-container", ".bpx-docker", ".bpx-player-video-area", ".bpx-player-primary-area", ".bpx-player-inner", ".scroll-sticky", "#bilibili-player", "#playerWrap", ".player-wrap"], // /video/ and /list/, 番剧 (#bilibili-player-wrap), 活动页 (.festival-video-player). playerWrap: ["#playerWrap", ".player-wrap", "#bilibili-player-wrap", ".festival-video-player"], + // The server-rendered page until the client takes it over (content.js pageHydrating). + hydrating: ["[data-server-rendered]"], miniPlayer: [".bpx-player-mini-warp", ".bpx-player-mini-close"], miniClose: [".bpx-player-mini-close"], endingPanel: [".bpx-player-ending-panel", ".bpx-player-ending-related"],