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
2 changes: 1 addition & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣
- 打开分拣台不等 B站:先用上次读到的收藏夹列表,打开上次那个收藏夹的快照(稍后再看也一样,随后照常重读它),同时在后台重新读收藏夹列表。读回来只更新收藏夹列表和数量;打开的收藏夹还在勾选里就不重开,页签、筛选、选中都留着。在收藏夹设置里勾选或取消勾选别的收藏夹也一样,「所有收藏夹」「已出分拣范围」在勾选变了时重开。
- 收藏夹设置在别处改了(设置页「导入设置」、AI 打标签弹窗里的「允许 AI 去掉已有标签」),开着的收藏夹马上用新值,和关注、摘读记录读自己的设置一样。收藏夹设置、关注设置、摘读记录设置的弹窗开着时别处改了设置,「保存」只写弹窗里改过的项,没动的项留别处写的值(`TriageUi.changedFields`);收藏夹设置里的收藏夹勾选也一样,没动勾选就不写。为什么:保存把弹窗里的旧值全写回去,会把刚导入的设置改回去。
- 打开弹窗、打开或切回侧边栏、进入专注模式都不重新读视频:页面已经读好的直接用(没有字幕的也不重读),正在读的等它读完,还没读的才读;只有点「刷新」才重新读。站内换视频时,专注模式开着就马上读新视频;没开时页面不自己读,等侧边栏(跟着标签页)、弹窗或播放器上的 ✦ 来要时才读一次,几个入口拿到的都是这一次,不会先闪回上一个视频。为什么:同一个视频反复读只会多碰 B站 的限流,也让几个入口看到的不一样。
- 侧边栏在视频的标题栏里有「字幕」下拉框(标题下面一行,弹窗那一条的样式,32px 高;普通网页不显示),和弹窗、专注模式选的是同一条字幕:哪边换了,另外两边跟着变(视频页换完发 `mdg-subtitle-changed`)。下拉框的内容和弹窗一样(`MdgSites.fillSubtitleSelect`):只有一条照常能点,没有字幕时灰掉、框里写原因;换的时候写「正在切换字幕…」,失败写「字幕切换失败:原因」,侧边栏写在对话区的提示条里,对话已经开始时换成功会提示「已切换字幕:…」。AI 正在回答时灰掉(「回答完才能换字幕」):这条回答用的是提问时的字幕。视频页关了、从别处读出来的历史对话只显示它的字幕,灰掉写「在这个视频的页面上才能换字幕」。换了以后下一次提问就用新字幕,已保存的对话也一样。为什么:开着侧边栏切视频时,不用再打开弹窗去选字幕(2026-10-25 用户定位置和样式)。
- 侧边栏在视频的标题栏里有「字幕」下拉框(标题下面一行,弹窗那一条的样式,32px 高;普通网页不显示),和弹窗、专注模式选的是同一条字幕:哪边换了,另外两边跟着变(视频页换完发 `mdg-subtitle-changed`)。下拉框的内容和弹窗一样(`MdgSites.fillSubtitleSelect`):只有一条照常能点,没有字幕时灰掉、框里写原因;换的时候写「正在切换字幕…」,失败写「字幕切换失败:原因」,侧边栏写在对话区的提示条里,对话已经开始时换成功会提示「已切换字幕:…」。AI 正在回答时灰掉(「回答完才能换字幕」):这条回答用的是提问时的字幕。视频页关了、从别处读出来的历史对话只显示它的字幕,灰掉写「在这个视频的页面上才能换字幕」。换了以后下一次提问就用新字幕,已保存的对话也一样。为什么:开着侧边栏切视频时,不用再打开弹窗去选字幕(2026-10-25 用户定位置和样式)。字幕下拉框后面有「刷新」,和弹窗右上角的一样:重新读视频和字幕,读的时候图标转圈、不能点,读失败变红,下次读成功再恢复;「重试」也走它,一样转圈。为什么:刷新就是重新读字幕,放在字幕旁边(2026-10-26 用户定)。
- 设置页「带上前 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
1 change: 1 addition & 0 deletions extension/conventions.selftest.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const ALLOW = [
["options.js", "button.disabled = Boolean(active);", "保存和测试连接互等:在跑的那个写「保存中…」「测试中…」并 aria-busy"],
["options.js", "testBtn.disabled = true;", "AI 平台测试中:同一行写「正在测试…」并 aria-busy,完了 setReason 接手"],
["popup.js", "button.disabled = busy;", "弹窗按钮在跑:按钮文字换成在做什么,aria-busy"],
["sidepanel.js", "els.refreshBtn.disabled = true;", "侧边栏刷新在读:图标转圈并 aria-busy,finally 打开"],
["triage/fav-base.js", "btn.disabled = Boolean(text);", "分拣台按钮在跑:按钮文字换成在做什么,aria-busy"],
["triage/follow.js", "btn.disabled = syncing(j);", "关注刷新在跑:aria-busy,进度胶囊写进度"],
["triage/follow.js", "btn.disabled = true;", "点了刷新(第 2 行、空状态或提示里的)、后台还没回:回来后 renderSync 接手(aria-busy)或失败时打开"],
Expand Down
12 changes: 11 additions & 1 deletion extension/dev-sidepanel/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,17 @@
<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>
<div class="sp-subline" id="spSubtitleRow" hidden>
<label class="sp-subrow"><span class="k">字幕</span><select id="spSubtitleSelect"></select></label>
<button id="spRefreshBtn" type="button" class="sp-icon-btn" title="刷新" aria-label="刷新" data-text="refresh">
<svg viewBox="0 0 24 24" focusable="false" aria-hidden="true">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path>
<path d="M8 16H3v5"></path>
</svg>
</button>
</div>
</header>
<section id="spPreviousVideo" class="sp-prev" hidden></section>
<main class="sp-messages" id="spMessages">
Expand Down
40 changes: 33 additions & 7 deletions extension/sidepanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,45 @@ body {
flex-shrink: 0;
}

/* 字幕 under the video, the popup's bar (popup.css .subrow), 32px. */
.sp-header:has(.sp-subrow:not([hidden])) {
/* 字幕 under the video, the popup's bar (popup.css .subrow), 32px, with 刷新 after it. */
.sp-header:has(.sp-subline:not([hidden])) {
flex-wrap: wrap;
row-gap: 8px;
}

.sp-subrow {
/* Same gap and width as the row above, so 刷新 sits under 设置 and the bar ends where the model select does. */
.sp-subline {
flex-basis: 100%;
display: flex;
gap: 8px;
min-width: 0;
}

.sp-subline[hidden] {
display: none;
}

.sp-subline .sp-icon-btn {
height: 32px;
border: 1px solid transparent;
}

/* While reading, the refresh icon itself spins instead of the shared spinner; red only when the last read failed (popup.css .icon-btn). */
.sp-subline .sp-icon-btn[aria-busy="true"]::before { content: none; }
.sp-subline .sp-icon-btn[aria-busy="true"] svg { animation: busy-spin 0.8s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.sp-subline .sp-icon-btn[aria-busy="true"] svg { animation: none; }
}

.sp-subline .sp-icon-btn.is-error {
color: var(--danger);
background: var(--danger-bg);
border-color: var(--danger-border);
}

.sp-subrow {
flex: 1;
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
Expand All @@ -45,10 +75,6 @@ body {
font-size: var(--fs-sm);
}

.sp-subrow[hidden] {
display: none;
}

.sp-subrow .k {
color: var(--faint);
white-space: nowrap;
Expand Down
12 changes: 11 additions & 1 deletion extension/sidepanel.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,17 @@
<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>
<div class="sp-subline" id="spSubtitleRow" hidden>
<label class="sp-subrow"><span class="k">字幕</span><select id="spSubtitleSelect"></select></label>
<button id="spRefreshBtn" type="button" class="sp-icon-btn" title="刷新" aria-label="刷新" data-text="refresh">
<svg viewBox="0 0 24 24" focusable="false" aria-hidden="true">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path>
<path d="M8 16H3v5"></path>
</svg>
</button>
</div>
</header>
<section id="spPreviousVideo" class="sp-prev" hidden></section>
<main class="sp-messages" id="spMessages">
Expand Down
9 changes: 9 additions & 0 deletions extension/sidepanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const els = {
modelSelect: document.getElementById("spModelSelect"),
subtitleRow: document.getElementById("spSubtitleRow"),
subtitleSelect: document.getElementById("spSubtitleSelect"),
refreshBtn: document.getElementById("spRefreshBtn"),
settingsBtn: document.getElementById("spSettingsBtn"),
newChatBtn: document.getElementById("spNewChatBtn"),
presetBtn: document.getElementById("spPresetBtn"),
Expand Down Expand Up @@ -180,6 +181,7 @@ function bindEvents() {
});
els.previousVideoBar?.addEventListener("click", handlePreviousVideoBarClick);
els.subtitleSelect.addEventListener("change", () => void pickSubtitle());
els.refreshBtn.addEventListener("click", () => void refreshContextManually());
chrome.runtime.onMessage.addListener((message, sender) => {
if (message?.type === "mdg-subtitle-changed") {
if (subtitleFrom(sender)) void rereadShownContext();
Expand Down Expand Up @@ -781,6 +783,7 @@ async function readContextState({ refresh = false, silent = false, follow = fals
}
return false;
}
els.refreshBtn.classList.remove("is-error");
if (effects.some((effect) => effect.type === "followed")) {
return FOLLOWED_LIVE_VIDEO;
}
Expand Down Expand Up @@ -1545,8 +1548,12 @@ async function refreshContextManually() {
return;
}
refreshingContext = true;
// 刷新 after 字幕 spins for every forced read (重试 too) and turns red when it failed (until a read succeeds), as the popup's does.
els.refreshBtn.disabled = true;
els.refreshBtn.setAttribute("aria-busy", "true");
try {
const ok = await loadContextState({ refresh: true });
if (!ok) els.refreshBtn.classList.add("is-error");
// A question sent while it read: redrawing the empty state would wipe it and its reply.
if (ok && !state.view().streaming) {
const { context, messages } = state.view();
Expand All @@ -1558,6 +1565,8 @@ async function refreshContextManually() {
}
} finally {
refreshingContext = false;
els.refreshBtn.disabled = false;
els.refreshBtn.setAttribute("aria-busy", "false");
}
}

Expand Down
12 changes: 12 additions & 0 deletions extension/sidepanel.selftest.js
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,18 @@ const tick = () => new Promise((r) => setImmediate(r));
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]);
// 刷新 after 字幕 reads again: busy while it reads, red after a failed read until one succeeds.
const realBtn = els.refreshBtn;
const classes = new Set();
const attrs = {};
els.refreshBtn = { disabled: false, setAttribute: (k, v) => (attrs[k] = v), classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c) } };
answer = async () => (assert.deepStrictEqual([els.refreshBtn.disabled, attrs["aria-busy"]], [true, "true"]), { ok: false, error: "B站限流,请稍后再试" });
await ctx.refreshContextManually();
assert.deepStrictEqual([els.refreshBtn.disabled, attrs["aria-busy"], classes.has("is-error")], [false, "false", true]);
answer = async () => ({ ok: true, payload: { ...video, subtitleOptions: tracks("u2"), subtitleMissing: "视频没有字幕" } });
await ctx.refreshContextManually();
assert.strictEqual(classes.has("is-error"), false);
els.refreshBtn = realBtn;
// A video read without its page (closed) only says its track.
show({ ...video, subtitleLang: "中文" });
ctx.syncSendUi();
Expand Down