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,