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
1 change: 1 addition & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 的设置监听)。为什么:同一个弹窗里几个按钮给出不同的笔记,分不清哪个对。
Expand Down
37 changes: 12 additions & 25 deletions extension/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || []
}
};
Expand Down Expand Up @@ -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) {
Expand All @@ -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] || ""}`;
Expand All @@ -1261,30 +1271,7 @@ function noTrackText() {
return MdgErrors.subtitleMissing(state);
}

function renderSubtitleOptions(select) {
const subtitles = state.subtitles || [];

if (subtitles.length === 0) {
select.innerHTML = `<option value="">${escapeHtml(noTrackText())}</option>`;
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) ? "" : `<option value="" selected disabled>${escapeHtml(noTrackText())}</option>`;
select.innerHTML = none + options
.map(
(item) =>
`<option value="${escapeHtml(item.url)}" data-lang="${escapeHtml(item.lang)}" data-id="${escapeHtml(item.id)}" ${
item.selected ? "selected" : ""
}>${escapeHtml(item.optionLabel)}</option>`
)
.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 || "";
Expand Down
5 changes: 3 additions & 2 deletions extension/content.selftest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 1 addition & 2 deletions extension/conventions.selftest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", "上下文胶囊只是跳到视频地址:没有地址时胶囊上写着「正在加载…」或「未知视频」"],
Expand Down
1 change: 1 addition & 0 deletions extension/dev-sidepanel/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<label class="sp-subrow" id="spSubtitleRow" hidden><span class="k">字幕</span><select id="spSubtitleSelect"></select></label>
</header>
<section id="spPreviousVideo" class="sp-prev" hidden></section>
<main class="sp-messages" id="spMessages">
Expand Down
2 changes: 2 additions & 0 deletions extension/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 重试.
Expand Down
5 changes: 3 additions & 2 deletions extension/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"] },

Expand Down
24 changes: 2 additions & 22 deletions extension/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = `<option value="">${escapeHtml(payload.subtitleMissing || MdgErrors.subtitleMissing())}</option>`;
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)
? ""
: `<option value="" selected disabled>${escapeHtml(payload.subtitleMissing || MdgErrors.subtitleMissing())}</option>`;
el.subtitleSelect.innerHTML = none + options
.map((item) => {
const selected = item.selected ? "selected" : "";
return `<option value="${escapeHtml(item.url)}" data-id="${escapeHtml(
item.id || ""
)}" data-lang="${escapeHtml(item.lang || "")}" ${selected}>${escapeHtml(
item.optionLabel || item.lang || "unknown"
)}</option>`;
})
.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;
Expand Down
3 changes: 2 additions & 1 deletion extension/sidepanel-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions extension/sidepanel-state.selftest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
48 changes: 48 additions & 0 deletions extension/sidepanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions extension/sidepanel.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<label class="sp-subrow" id="spSubtitleRow" hidden><span class="k">字幕</span><select id="spSubtitleSelect"></select></label>
</header>
<section id="spPreviousVideo" class="sp-prev" hidden></section>
<main class="sp-messages" id="spMessages">
Expand Down
Loading
Loading