diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6792dd2..ba81796 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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` 的设置监听)。为什么:同一个弹窗里几个按钮给出不同的笔记,分不清哪个对。 diff --git a/extension/conventions.selftest.js b/extension/conventions.selftest.js index 3af9383..f1c2254 100644 --- a/extension/conventions.selftest.js +++ b/extension/conventions.selftest.js @@ -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)或失败时打开"], diff --git a/extension/dev-sidepanel/index.html b/extension/dev-sidepanel/index.html index 828a75a..a34173e 100644 --- a/extension/dev-sidepanel/index.html +++ b/extension/dev-sidepanel/index.html @@ -17,7 +17,17 @@ - +
diff --git a/extension/sidepanel.css b/extension/sidepanel.css index ffed5d9..907e3d2 100644 --- a/extension/sidepanel.css +++ b/extension/sidepanel.css @@ -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; @@ -45,10 +75,6 @@ body { font-size: var(--fs-sm); } -.sp-subrow[hidden] { - display: none; -} - .sp-subrow .k { color: var(--faint); white-space: nowrap; diff --git a/extension/sidepanel.html b/extension/sidepanel.html index 1498f16..545e2af 100644 --- a/extension/sidepanel.html +++ b/extension/sidepanel.html @@ -17,7 +17,17 @@ - +
diff --git a/extension/sidepanel.js b/extension/sidepanel.js index de9eebd..ba03427 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -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"), @@ -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(); @@ -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; } @@ -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(); @@ -1558,6 +1565,8 @@ async function refreshContextManually() { } } finally { refreshingContext = false; + els.refreshBtn.disabled = false; + els.refreshBtn.setAttribute("aria-busy", "false"); } } diff --git a/extension/sidepanel.selftest.js b/extension/sidepanel.selftest.js index a851177..b4cfe07 100644 --- a/extension/sidepanel.selftest.js +++ b/extension/sidepanel.selftest.js @@ -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();