From a56450ecbb0a2c0cf474cd47608050533f04f716 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:33:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F=EF=BC=9A=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=A0=8F=E4=B8=8B=E5=8A=A0=E5=AD=97=E5=B9=95=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E6=A1=86=EF=BC=8C=E5=92=8C=E5=BC=B9=E7=AA=97=E3=80=81?= =?UTF-8?q?=E4=B8=93=E6=B3=A8=E6=A8=A1=E5=BC=8F=E9=80=89=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=E5=AD=97=E5=B9=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN.md | 1 + extension/content.js | 37 +++++--------- extension/content.selftest.js | 5 +- extension/conventions.selftest.js | 3 +- extension/dev-sidepanel/index.html | 1 + extension/errors.js | 2 + extension/messages.js | 5 +- extension/popup.js | 24 +-------- extension/sidepanel-state.js | 3 +- extension/sidepanel-state.selftest.js | 9 ++++ extension/sidepanel.css | 48 +++++++++++++++++ extension/sidepanel.html | 1 + extension/sidepanel.js | 74 +++++++++++++++++++++++++++ extension/sidepanel.selftest.js | 55 ++++++++++++++++++++ extension/sites.js | 16 ++++++ 15 files changed, 230 insertions(+), 54 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7658d027..6792dd21 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -108,6 +108,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 - 打开分拣台不等 B站:先用上次读到的收藏夹列表,打开上次那个收藏夹的快照(稍后再看也一样,随后照常重读它),同时在后台重新读收藏夹列表。读回来只更新收藏夹列表和数量;打开的收藏夹还在勾选里就不重开,页签、筛选、选中都留着。在收藏夹设置里勾选或取消勾选别的收藏夹也一样,「所有收藏夹」「已出分拣范围」在勾选变了时重开。 - 收藏夹设置在别处改了(设置页「导入设置」、AI 打标签弹窗里的「允许 AI 去掉已有标签」),开着的收藏夹马上用新值,和关注、摘读记录读自己的设置一样。收藏夹设置、关注设置、摘读记录设置的弹窗开着时别处改了设置,「保存」只写弹窗里改过的项,没动的项留别处写的值(`TriageUi.changedFields`);收藏夹设置里的收藏夹勾选也一样,没动勾选就不写。为什么:保存把弹窗里的旧值全写回去,会把刚导入的设置改回去。 - 打开弹窗、打开或切回侧边栏、进入专注模式都不重新读视频:页面已经读好的直接用(没有字幕的也不重读),正在读的等它读完,还没读的才读;只有点「刷新」才重新读。站内换视频时,专注模式开着就马上读新视频;没开时页面不自己读,等侧边栏(跟着标签页)、弹窗或播放器上的 ✦ 来要时才读一次,几个入口拿到的都是这一次,不会先闪回上一个视频。为什么:同一个视频反复读只会多碰 B站 的限流,也让几个入口看到的不一样。 +- 侧边栏在视频的标题栏里有「字幕」下拉框(标题下面一行,弹窗那一条的样式,32px 高;普通网页不显示),和弹窗、专注模式选的是同一条字幕:哪边换了,另外两边跟着变(视频页换完发 `mdg-subtitle-changed`)。下拉框的内容和弹窗一样(`MdgSites.fillSubtitleSelect`):只有一条照常能点,没有字幕时灰掉、框里写原因;换的时候写「正在切换字幕…」,失败写「字幕切换失败:原因」,侧边栏写在对话区的提示条里,对话已经开始时换成功会提示「已切换字幕:…」。AI 正在回答时灰掉(「回答完才能换字幕」):这条回答用的是提问时的字幕。视频页关了、从别处读出来的历史对话只显示它的字幕,灰掉写「在这个视频的页面上才能换字幕」。换了以后下一次提问就用新字幕,已保存的对话也一样。为什么:开着侧边栏切视频时,不用再打开弹窗去选字幕(2026-10-25 用户定位置和样式)。 - 设置页「带上前 N 条热门评论」关着时,没有字幕的视频照样带热门评论(视频页、弹窗、侧边栏、「逐个视频笔记」都一样),设置页这一行下面写明。为什么:没有字幕时笔记只剩简介,评论是这时主要的内容。 - 收藏夹的「一篇摘录」里每个视频带的和这个视频自己的笔记一样(`MdgNote.triageExtras`):AI 总结连同「AI 判断:…」那一行、所有收藏夹的分拣标签加摘读记录的标签、星标、备注。「一篇摘录」和「逐个视频笔记」的索引写入 Obsidian 时,放在笔记目录模板里第一个按视频取值的变量(`{{author}}` `{{upload_date}}` `{{id}}` `{{video_id}}` `{{created}}`)所在那一层之前(`MdgNote.sharedNoteFolder`):`MoonDigest/{{site}}/{{author}}` 放 `MoonDigest/bilibili`,`{{upload_date}}/{{site}}` 放库的根目录。为什么:一篇里有很多视频,没有一个作者或日期能代表它;按整层截,不会出现 `bilibili-` 这种半截的目录名。 - 改了笔记相关的设置(附带热门评论、笔记属性、默认标签……)以后,开着的视频页马上按新设置重新生成笔记:弹窗的复制、下载、详情和侧边栏拿到的,和这时写入 Obsidian 的一样(`content.js` 的设置监听)。为什么:同一个弹窗里几个按钮给出不同的笔记,分不清哪个对。 diff --git a/extension/content.js b/extension/content.js index 219fc33d..7b6bd934 100644 --- a/extension/content.js +++ b/extension/content.js @@ -971,6 +971,9 @@ function buildSidepanelContext() { subtitleLang: state.selectedSubtitleLang || "", selectedSubtitleId: state.selectedSubtitleId || "", selectedSubtitleUrl: state.selectedSubtitleUrl || "", + // The side panel's 字幕 dropdown, as the popup's. + subtitleOptions: subtitleOptionItems(), + subtitleMissing: noTrackText(), hotComments: state.hotComments || [] } }; @@ -1223,7 +1226,10 @@ async function switchSubtitle(url) { if (!pick.alive) return; throw MdgCommon.isStale(error) ? new Error(VIDEO_SWITCHED) : error; } - if (pick.alive) setStatus(`${state.subtitleWarning}字幕切换完成。`); + if (!pick.alive) return; + setStatus(`${state.subtitleWarning}字幕切换完成。`); + // The side panel shows the track too: one picked here (popup, 专注模式, the panel itself) is drawn there. + chrome.runtime.sendMessage({ type: "mdg-subtitle-changed" })?.catch?.(() => {}); } function onReadingSubtitleChange(event) { @@ -1242,6 +1248,10 @@ function renderReadingSubtitleSelect() { renderSubtitleOptions(byId(ids.readingSubtitleSelect)); } +function renderSubtitleOptions(select) { + MdgSites.fillSubtitleSelect(select, subtitleOptionItems(), noTrackText()); +} + // Translated tracks already say 机器翻译 in their label. function subtitleOptionLabel(item) { return `${item.label || item.lang || UNNAMED_SUBTITLE}${{ ai: "(AI)", auto: "(自动)" }[item.kind] || ""}`; @@ -1261,30 +1271,7 @@ function noTrackText() { return MdgErrors.subtitleMissing(state); } -function renderSubtitleOptions(select) { - const subtitles = state.subtitles || []; - - if (subtitles.length === 0) { - select.innerHTML = ``; - select.disabled = true; - return; - } - - const options = subtitleOptionItems(); - // No track loaded (all failed, or still loading): the dropdown says why instead of showing the first one as current. - const none = options.some((item) => item.selected) ? "" : ``; - select.innerHTML = none + options - .map( - (item) => - `` - ) - .join(""); - select.disabled = false; -} - -// The subtitle dropdown's entries, for 专注模式's and the popup's. +// The subtitle dropdown's entries, for 专注模式's, the popup's and the side panel's. function subtitleOptionItems() { return (state.subtitles || []).map((item) => { const label = item.label || item.lang || ""; diff --git a/extension/content.selftest.js b/extension/content.selftest.js index df262814..6b3b32ec 100644 --- a/extension/content.selftest.js +++ b/extension/content.selftest.js @@ -847,8 +847,9 @@ const settle = async () => { assert.deepStrictEqual([state.selectedSubtitleId, state.subtitleBody.length, state.subtitleWarning, state.subtitleFetchState], [".zh", 2, warning, "ready"]); // The side panel gets the warning too, and shows it with the reply (background.js buildAiMessages). assert.strictEqual(t.buildSidepanelContext().payload.subtitleWarning, warning); - // L6: the side panel gets the note it sends the AI, not the subtitle array or the track list (nobody reads them). - assert.ok(!["subtitleBody", "subtitleOptions"].some((k) => k in t.buildSidepanelContext().payload)); + // L6: the side panel gets the note it sends the AI, not the subtitle array; the track list draws its 字幕 dropdown. + assert.ok(!("subtitleBody" in t.buildSidepanelContext().payload)); + assert.deepStrictEqual(JSON.parse(JSON.stringify(t.buildSidepanelContext().payload.subtitleOptions)), [{ id: ".zh", url: "u1", lang: "中文", optionLabel: "中文", selected: true }]); // B站's logged-out list: 未登录 is its own state, not a failure reason, and the side panel hears it as such. { const site = t.currentSite; diff --git a/extension/conventions.selftest.js b/extension/conventions.selftest.js index 584e3216..3af93839 100644 --- a/extension/conventions.selftest.js +++ b/extension/conventions.selftest.js @@ -44,8 +44,7 @@ const L = load("limits.js", "MdgLimits"); // 3a, 3b, 4, 5: a hit that stays, and why: file + a snippet of its line + the reason (例外, or 待修 with the scan's id). const ALLOW = [ // The control itself says why: its only option is the reason. - ["content.js", "select.disabled = true;", "专注模式的字幕下拉框:唯一一项就是 noTrackText() 的原因(写在同一行)"], - ["popup.js", "el.subtitleSelect.disabled = true;", "弹窗字幕下拉框:唯一一项就是 subtitleMissing 的原因"], + ["sites.js", "select.disabled = true;", "字幕下拉框(专注模式、弹窗、侧边栏共用 fillSubtitleSelect):唯一一项就是 noTrackText() 的原因"], ["sidepanel.js", "els.modelSelect.disabled = true;", "侧边栏平台下拉框:唯一一项就是 TEXT.aiNone"], ["sidepanel.js", "els.contextChip.disabled = true;", "上下文胶囊读取失败:胶囊上写着「读取失败」"], ["sidepanel.js", "els.contextChip.disabled = !String(context.url", "上下文胶囊只是跳到视频地址:没有地址时胶囊上写着「正在加载…」或「未知视频」"], diff --git a/extension/dev-sidepanel/index.html b/extension/dev-sidepanel/index.html index 34843312..828a75ae 100644 --- a/extension/dev-sidepanel/index.html +++ b/extension/dev-sidepanel/index.html @@ -17,6 +17,7 @@ +
diff --git a/extension/errors.js b/extension/errors.js index 78c9757d..db260ef6 100644 --- a/extension/errors.js +++ b/extension/errors.js @@ -54,6 +54,8 @@ readerStartFailed: "专注模式启动失败", // The chosen subtitle track could not be loaded; followed by 「:原因」. subtitleSwitchFailed: "字幕切换失败", + // A track picked in the popup's or the side panel's 字幕 dropdown, while it loads. + subtitleSwitching: "正在切换字幕…", // 复制 Markdown, 下载 .md or 写入 Obsidian before the page has a note. noNoteYet: "还没有笔记内容,请先点「刷新」", // A side panel reply that ended without one word of answer (only thinking, or nothing); the panel offers 重试. diff --git a/extension/messages.js b/extension/messages.js index 90910517..7e09fb2e 100644 --- a/extension/messages.js +++ b/extension/messages.js @@ -46,9 +46,10 @@ "fetch-json": { from: ["content.js"], to: "background.js", request: ["url", "id"], response: ["data", "status"] }, "fetch-json-cancel": { from: ["content.js"], to: "background.js", request: ["id"], response: [] }, "mdg-video-changed": { from: ["content.js"], to: "sidepanel.js", request: [], response: [] }, + "mdg-subtitle-changed": { from: ["content.js"], to: "sidepanel.js", request: [], response: [] }, "popup-get-state": { from: ["popup.js"], to: "content.js", request: [], response: ["payload"], builder: "content.js:getPopupPayload", payload: POPUP_PAYLOAD }, "popup-refresh": { from: ["popup.js", "sidepanel.js", "background.js"], to: "content.js", request: ["refresh"], response: ["payload"] }, - "popup-select-subtitle": { from: ["popup.js"], to: "content.js", request: ["url"], response: ["payload"] }, + "popup-select-subtitle": { from: ["popup.js", "sidepanel.js"], to: "content.js", request: ["url"], response: ["payload"] }, "popup-send-obsidian": { from: ["popup.js"], to: "content.js", request: [], response: ["text", "written", "payload"] }, "popup-trigger-reading-view": { from: ["background.js"], to: "content.js", request: ["readerUrl"], response: [] }, "sidepanel-get-context": { @@ -57,7 +58,7 @@ request: ["refresh", "videoId"], response: ["payload"], builder: "content.js:buildSidepanelContext", - payload: ["url", "title", "author", "uploadDate", "site", "videoId", "cid", "aid", "description", "pageIndex", "pageCount", "pageTitle", "subtitleMarkdown", "subtitleFailure", "subtitleNeedLogin", "subtitleWarning", "subtitleLang", "selectedSubtitleId", "selectedSubtitleUrl", "hotComments"] + payload: ["url", "title", "author", "uploadDate", "site", "videoId", "cid", "aid", "description", "pageIndex", "pageCount", "pageTitle", "subtitleMarkdown", "subtitleFailure", "subtitleNeedLogin", "subtitleWarning", "subtitleLang", "selectedSubtitleId", "selectedSubtitleUrl", "subtitleOptions", "subtitleMissing", "hotComments"] }, "sidepanel-seek-video-time": { from: ["sidepanel.js"], to: "content.js", request: ["seconds", "videoId"], response: ["currentTime"] }, diff --git a/extension/popup.js b/extension/popup.js index ded7d988..56d867a4 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -198,7 +198,7 @@ function bindEvents() { if (!url) { return; } - setStatus("正在切换字幕…", false, true); + setStatus(MdgErrors.TEXT.subtitleSwitching, false, true); const reply = replies.next(); const resp = await sendToContent({ type: "popup-select-subtitle", url }); if (!reply.alive) return; @@ -335,27 +335,7 @@ function render(payload, { preserveStatus = false } = {}) { el.videoDuration.hidden = !el.videoDuration.textContent; el.propUrl.title = payload.url || ""; - const options = payload.subtitleOptions || []; - if (options.length === 0) { - el.subtitleSelect.innerHTML = ``; - el.subtitleSelect.disabled = true; - } else { - // No track loaded: the reason (the page's noTrackText), not the first track shown as current. - const none = options.some((item) => item.selected) - ? "" - : ``; - el.subtitleSelect.innerHTML = none + options - .map((item) => { - const selected = item.selected ? "selected" : ""; - return ``; - }) - .join(""); - el.subtitleSelect.disabled = false; - } + MdgSites.fillSubtitleSelect(el.subtitleSelect, payload.subtitleOptions || [], payload.subtitleMissing || MdgErrors.subtitleMissing()); el.preview.value = payload.subtitlePreview || ""; el.preview.hidden = !el.preview.value; diff --git a/extension/sidepanel-state.js b/extension/sidepanel-state.js index 114c8cf1..76b01eac 100644 --- a/extension/sidepanel-state.js +++ b/extension/sidepanel-state.js @@ -595,7 +595,8 @@ return follow(s, payload); } if (decision.action === "keep") { - if (s.conv.pinned && !s.conv.resolved && fitsLive(s)) s.conv.resolved = { ...payload }; + // Read again like at each send: a track picked since (the popup, 专注模式, the 字幕 dropdown) is in it. + if (s.conv.pinned && fitsLive(s)) s.conv.resolved = { ...payload }; return []; } return keyChanged ? [...setConv(s), ...restoreLatest(s, payload)] : pageRead(s, payload, key); diff --git a/extension/sidepanel-state.selftest.js b/extension/sidepanel-state.selftest.js index 1f8c7732..a052217c 100644 --- a/extension/sidepanel-state.selftest.js +++ b/extension/sidepanel-state.selftest.js @@ -744,4 +744,13 @@ function panel({ tab = video("BV1aa"), list = [] } = {}) { assert.strictEqual(panel().view().turnReply, null, "idle: nothing in flight"); } +// A saved conversation on its own video's tab takes the tab's later reads: a track picked since (the popup, 专注模式, +// the 字幕 dropdown) is what the next question is asked with. +{ + const p = panel({ tab: video("BV1hh", { subtitleLang: "中文" }), list: [saved("h", video("BV1hh"))] }); + assert.deepStrictEqual([p.view().id, p.view().context.subtitleLang], ["h", "中文"]); + p.load(video("BV1hh", { subtitleLang: "英语" })); + assert.strictEqual(p.view().context.subtitleLang, "英语"); +} + console.log("sidepanel-state selftest: all passed"); diff --git a/extension/sidepanel.css b/extension/sidepanel.css index f94cbef9..ffed5d92 100644 --- a/extension/sidepanel.css +++ b/extension/sidepanel.css @@ -26,6 +26,54 @@ body { flex-shrink: 0; } +/* 字幕 under the video, the popup's bar (popup.css .subrow), 32px. */ +.sp-header:has(.sp-subrow:not([hidden])) { + flex-wrap: wrap; + row-gap: 8px; +} + +.sp-subrow { + flex-basis: 100%; + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; + height: 32px; + padding-left: 10px; + background: var(--field-bg); + border-radius: var(--radius-md); + font-size: var(--fs-sm); +} + +.sp-subrow[hidden] { + display: none; +} + +.sp-subrow .k { + color: var(--faint); + white-space: nowrap; +} + +#spSubtitleSelect { + flex: 1; + min-width: 0; + height: 100%; + border: 0; + border-radius: var(--radius-md); + padding: 0 28px 0 0; + appearance: none; + background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239499a0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") right 10px center / 14px no-repeat; + color: var(--text); + font: inherit; + text-overflow: ellipsis; + cursor: pointer; +} + +#spSubtitleSelect:disabled { + color: var(--faint); + cursor: default; +} + /* The video row takes what is left; the model select stays narrow (full name in its tooltip and options). */ .sp-context-chip { flex: 1 1 0; diff --git a/extension/sidepanel.html b/extension/sidepanel.html index d543dd59..1498f16e 100644 --- a/extension/sidepanel.html +++ b/extension/sidepanel.html @@ -17,6 +17,7 @@ +
diff --git a/extension/sidepanel.js b/extension/sidepanel.js index 12979aa1..95159b58 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -35,6 +35,8 @@ const els = { contextMeta: document.querySelector("#spContextChip .sp-context-meta"), previousVideoBar: document.getElementById("spPreviousVideo"), modelSelect: document.getElementById("spModelSelect"), + subtitleRow: document.getElementById("spSubtitleRow"), + subtitleSelect: document.getElementById("spSubtitleSelect"), settingsBtn: document.getElementById("spSettingsBtn"), newChatBtn: document.getElementById("spNewChatBtn"), presetBtn: document.getElementById("spPresetBtn"), @@ -177,7 +179,12 @@ function bindEvents() { void startNewConversation(); }); els.previousVideoBar?.addEventListener("click", handlePreviousVideoBarClick); + els.subtitleSelect.addEventListener("change", () => void pickSubtitle()); chrome.runtime.onMessage.addListener((message, sender) => { + if (message?.type === "mdg-subtitle-changed") { + if (subtitleFrom(sender)) void rereadShownContext(); + return false; + } if (message?.type !== "mdg-video-changed") { return false; } @@ -353,6 +360,73 @@ function syncSendUi() { els.sendBtn.classList.toggle("is-stop", streaming); els.sendBtn.innerHTML = streaming ? (stopping ? MdgErrors.LABEL.stopping : MdgErrors.LABEL.stop) : `${AI_SPARK}发送`; syncExportButtons(); + renderSubtitleRow(); +} + +// 字幕 under a video, as the popup's: the tracks of the page that plays it (the tab, or 分拣台's viewer frame), where +// a pick goes (popup-select-subtitle). A video read elsewhere (its page closed) only says its track. +const SUBTITLE_ELSEWHERE = "在这个视频的页面上才能换字幕"; +let subtitleSwitching = false; + +// Where the shown video plays: { frameId, tabId? (分拣台's viewer) }, null when no page this panel can ask does. +function subtitleTarget() { + const { context, key, live, pinned, resolved } = state.view(); + if (!Array.isArray(context?.subtitleOptions)) return null; + if (pinned && viewerFrame?.videoId === String(pinned.contextRef?.videoId || "") && context === resolved) return { tabId: viewerFrame.tabId, frameId: viewerFrame.frameId }; + return live.context && key === live.key ? { frameId: 0 } : null; +} + +// A track picked on a page (here, the popup, 专注模式): the page this panel's video plays on. +function subtitleFrom(sender) { + const target = subtitleTarget(); + return Boolean(target && sender?.tab?.id && (sender.frameId || 0) === target.frameId && (!target.tabId || target.tabId === sender.tab.id)); +} + +function renderSubtitleRow() { + const { context, turn } = state.view(); + const video = Boolean(context) && context.isVideoContext !== false; + els.subtitleRow.hidden = !video; + if (!video) return; + const select = els.subtitleSelect; + // The picked track stays shown while it loads. + if (subtitleSwitching) return setDisabledReason(select, MdgErrors.TEXT.subtitleSwitching); + const missing = context.pending ? MdgErrors.TEXT.subtitleReading : context.subtitleMissing || MdgErrors.subtitleMissing(context); + const target = subtitleTarget(); + if (target || context.pending) MdgSites.fillSubtitleSelect(select, context.pending ? [] : context.subtitleOptions, missing); + else select.innerHTML = ``; + // A reply answers from the track it was asked with. + const reason = turn !== "idle" ? "回答完才能换字幕" : !target && !context.pending ? SUBTITLE_ELSEWHERE : ""; + setDisabledReason(select, reason || (select.disabled ? missing : "")); +} + +// The page loads the track (as the popup's pick does) and tells every panel (mdg-subtitle-changed); this one reads +// its video again. A failure is said like the popup's, and the dropdown goes back to the track still loaded. +async function pickSubtitle() { + const select = els.subtitleSelect; + const url = select.value; + const label = select.selectedOptions[0]?.textContent || ""; + const target = subtitleTarget(); + if (!url || !target) return renderSubtitleRow(); + const { run } = state.view(); + subtitleSwitching = true; + renderSubtitleRow(); + const notice = showConversationContextNotice(MdgErrors.TEXT.subtitleSwitching); + const tab = await MdgSites.activeTab().catch(() => null); + const resp = tab?.id && (!target.tabId || target.tabId === tab.id) + ? await MdgErrors.tabMessage(tab.id, { type: "popup-select-subtitle", url }, { frameId: target.frameId }).catch((error) => ({ ok: false, error: MdgErrors.pageSilent(error).message })) + : { ok: false, error: SUBTITLE_ELSEWHERE }; + subtitleSwitching = false; + notice.remove(); + if (run.alive && !resp?.ok) showConversationContextNotice(`${MdgErrors.TEXT.subtitleSwitchFailed}:${MdgErrors.text(resp)}`, 0, { error: true }); + await rereadShownContext(); + if (run.alive && resp?.ok && state.view().messages.length) showConversationContextNotice(`已切换字幕:${label}`, 4000); +} + +// The shown video read again from its page (a track picked since), as a send reads it. +async function rereadShownContext() { + await loadContextState({ silent: true }).catch(() => false); + if (state.view().pinned) await hydratePinnedConversationContext({ silent: true }); + updateContextChip(); } // 导出对话 and each item in its menu: disabled with the reason while there is nothing to export. diff --git a/extension/sidepanel.selftest.js b/extension/sidepanel.selftest.js index b366ab59..29c864e0 100644 --- a/extension/sidepanel.selftest.js +++ b/extension/sidepanel.selftest.js @@ -841,6 +841,61 @@ const tick = () => new Promise((r) => setImmediate(r)); ctx.syncSendUi(); } + // 字幕 under the video: the page's tracks as the popup draws them; a pick goes to that page, and the video is read again. + { + const els = vm.runInContext("els", ctx); + const select = els.subtitleSelect; + const tracks = (picked) => ["u1", "u2"].map((url, i) => ({ id: String(i), url, lang: url, optionLabel: i ? "英语" : "中文", selected: url === picked })); + state.dispatch({ type: "NEW" }); + show({ ...video, subtitleOptions: tracks("u1"), subtitleMissing: "视频没有字幕" }); + ctx.syncSendUi(); + assert.deepStrictEqual([els.subtitleRow.hidden, select.disabled, select.title], [false, false, ""]); + assert.ok(/value="u1"[^>]*selected/.test(select.innerHTML) && select.innerHTML.includes("英语"), select.innerHTML); + // A reply answers from the track it was asked with. + ask("问"); + ctx.syncSendUi(); + assert.deepStrictEqual([select.disabled, select.title], [true, "回答完才能换字幕"]); + state.dispatch({ type: "STREAM_ENDED", raw: "答" }); + // A pick: popup-select-subtitle to the tab's top frame, then the video read again with the new track. + const sent = []; + const realNotice = ctx.showConversationContextNotice; + const notices = []; + ctx.showConversationContextNotice = (text, ms, opts) => (notices.push([text, Boolean(opts?.error)]), { remove() {} }); + activeTab = { id: 7, url: video.url, title: video.title }; + let reply = { ok: true }; + ctx.chrome.tabs.sendMessage = (tabId, msg, opts, cb) => (sent.push([tabId, msg, opts]), cb(reply)); + answer = async () => ({ ok: true, payload: { ...video, subtitleOptions: tracks("u2"), subtitleMissing: "视频没有字幕" } }); + select.value = "u2"; + await ctx.pickSubtitle(); + ctx.syncSendUi(); + assert.deepStrictEqual(JSON.parse(JSON.stringify(sent)), [[7, { type: "popup-select-subtitle", url: "u2" }, { frameId: 0 }]]); + assert.ok(/value="u2"[^>]*selected/.test(select.innerHTML), select.innerHTML); + assert.deepStrictEqual(notices.map(([t]) => t), [ctx.MdgErrors.TEXT.subtitleSwitching, "已切换字幕:"]); + // A failure says the popup's words; the dropdown shows the track still loaded. + notices.length = 0; + reply = { ok: false, error: "YouTube 限流,请稍后再试" }; + select.value = "u1"; + await ctx.pickSubtitle(); + ctx.syncSendUi(); + assert.deepStrictEqual(notices[1], ["字幕切换失败:YouTube 限流,请稍后再试", true]); + assert.ok(/value="u2"[^>]*selected/.test(select.innerHTML)); + // Picks made on that page (the popup, 专注模式) are followed; another frame's are not. + assert.deepStrictEqual([ctx.subtitleFrom({ tab: { id: 7 }, frameId: 0 }), ctx.subtitleFrom({ tab: { id: 7 }, frameId: 3 })], [true, false]); + // A video read without its page (closed) only says its track. + show({ ...video, subtitleLang: "中文" }); + ctx.syncSendUi(); + assert.deepStrictEqual([select.innerHTML, select.disabled, select.title], ["", true, "在这个视频的页面上才能换字幕"]); + // A web page has no 字幕. + state.dispatch({ type: "NEW" }); + show({ ...web("sub"), pageAccess: "ready", pageMarkdown: "正文" }); + ctx.syncSendUi(); + assert.strictEqual(els.subtitleRow.hidden, true); + ctx.showConversationContextNotice = realNotice; + delete ctx.chrome.tabs.sendMessage; + show(video); + ctx.syncSendUi(); + } + // 快捷追问's 添加: disabled with the reason while the box is empty or the list is full. { const els = vm.runInContext("els", ctx); diff --git a/extension/sites.js b/extension/sites.js index d3f504a4..4f3a2353 100644 --- a/extension/sites.js +++ b/extension/sites.js @@ -1562,6 +1562,21 @@ // The focused window's active tab (the popup's, the side panel's), or null. const activeTab = async () => (await chrome.tabs.query({ active: true, currentWindow: true }))?.[0] || null; + // The subtitle dropdown (专注模式, the popup, the side panel): options are the page's subtitleOptionItems, missing its + // noTrackText. No track: greyed with the reason. No track loaded (all failed, or still loading): the reason first, + // instead of the first track shown as current. + function fillSubtitleSelect(select, options, missing) { + const esc = MdgErrors.escapeHtml; + if (!options.length) { + select.innerHTML = ``; + select.disabled = true; + return; + } + const none = options.some((item) => item.selected) ? "" : ``; + select.innerHTML = none + options.map((item) => ``).join(""); + select.disabled = false; + } + globalThis.MdgSites = { SITES, waitTabLoaded, @@ -1590,6 +1605,7 @@ fetchRawCached, loadFirstTrack, loadSubtitle, + fillSubtitleSelect, biliMixinKey, biliWbiSign, biliWbiSigned,