/**
 * Bilibili 视频总结 - 侧边栏逻辑
 */
(function () {
  'use strict';

  const $ = (sel) => document.querySelector(sel);
  const $$ = (sel) => document.querySelectorAll(sel);
  const CREDIT_INSUFFICIENT_MESSAGE = '糟糕，学分不足，得升级会员（点击上方我的）或等待每日赠送学分刷新';
  const PROVIDER_CREDIT_INSUFFICIENT_MESSAGE = '上游模型服务暂时不可用，请联系管理员';
  const PROVIDER_CONFIG_ERROR_MESSAGE = '模型上游地址配置错误：请在服务端管理后台把 Base URL 设置为 OpenAI-compatible API 地址，例如 https://api.8612306.xyz/v1';
  // 总结流式响应超过该时长仍无第一个增量（后台无响应/服务端卡住）时放弃，避免侧栏永远停在「正在生成总结...」
  const SUMMARY_STALL_TIMEOUT_MS = 150000;
  const DEFAULT_SERVER_BASE_URL = 'https://api.cetle.cn';
  const FLOATING_BUTTON_ENABLED_KEY = 'biliSummaryFloatingButtonEnabled';
  const COLLECTION_HANDLE_DB_NAME = 'bili-summary-file-handles';
  const COLLECTION_HANDLE_STORE_NAME = 'handles';
  const COLLECTION_HANDLE_KEY = 'collection-directory';
  const SUMMARY_HISTORY_STORE_NAME = 'summary-history';
  const SUMMARY_HISTORY_LIMIT = 20;
  const CLOSED_MESSAGE_CHANNEL_PATTERN = /A listener indicated an asynchronous response|message (?:channel|port) closed before a response|The message port closed/i;
  window.addEventListener('unhandledrejection', (event) => {
    if (CLOSED_MESSAGE_CHANNEL_PATTERN.test(String(event.reason?.message || event.reason || ''))) {
      // 标签页切换/关闭时，Chromium 会主动终止仍在等待的扩展消息通道。
      // 这是正常生命周期，不应作为红色未处理错误打扰闲置侧栏。
      event.preventDefault();
    }
  });
  const LANGUAGE_OPTIONS = [
    ['zh-CN', '简体中文'], ['zh-TW', '繁體中文'], ['en', 'English'],
  ];
  const DISPLAY_LANGUAGE_LABEL = '显示语言 / 顯示語言 / Display language';
  const UI_TEXT = {
    'zh-CN': {
      settings: '设置', save: '保存设置', allMembers: '所有会员支持的设置', language: '显示语言', displayScale: '侧栏显示比例', scaleSmall: '小', scaleStandard: '标准', scaleLarge: '大', enableFloatingButton: '开启悬浮按钮快速开关侧栏', disableRealtimePageSwitch: '切换网页时不实时切换侧栏', includeTranscriptInSummaryBookmark: '收藏（保存）总结时，是否带上原字幕', autoSaveSummaryAsTxt: '总结过即保存为文本', saveTextsAsMarkdown: '所有文本保存为md而不是txt（适应obsidian）', mdHelpCopyLabel: '复制问号里的说明', mdHelpCopied: '说明已复制到剪贴板', saveTextsAsMarkdownHint: 'Chrome 会把保存的文件标记为「来自互联网」，打开时才弹安全提示，与 md/txt 格式无关。\n\n解除方法（任选其一）：\n① 批量解除（推荐）：按键盘 Win 键，输入 powershell，回车，打开黑色窗口（PowerShell，Windows 自带）。把下面命令中的【你的收藏夹路径】换成你保存文件的文件夹完整路径（打开该文件夹，从顶部地址栏复制），粘贴后回车：\nGet-ChildItem "你的收藏夹路径" -Recurse -Include *.md,*.txt | Unblock-File\n看到命令正常执行、没有红色报错即成功，之后打开文件不再弹提示。\n② 单个文件：右键文件 → 属性 → 常规 → 勾选「解除锁定」→ 确定。\n③ 或更换默认打开方式：右键文件 → 打开方式 → 选择 Obsidian 或记事本，勾选「始终使用」，以后打开也不再弹窗。', maxOutputTokens: '总结、追问建议输出的最大 Token', maxOutputTokensHint: '默认 3000；最好不超过该值，但内容完整优先，不会据此硬截断。', originalTranscript: '原字幕', history: '历史', summaryHistory: '切换总结历史', emptySummaryHistory: '当前还没有已保留的视频总结', historyHint: '保存最近20次总结的结果，如需长存还请及时收藏。点击即可跳转到相应总结，跳转总结后若需跳转原视频则点击顶部设置旁边的视频标题。只要点击过总结，即使在没完成总结时关闭侧栏或者切换到其他视频页，总结也会在服务器端传回总结历史，方便查阅❤️', historyHintCollapse: '收起', historyHintExpand: '展开', historyOpenSummary: '点击查看该条总结', historyOpenVideo: '跳转到视频', historyDelete: '清空该视频的总结历史', historyDeleted: '已清空该视频的总结和转写缓存',
      collectionFolder: '收藏夹所在路径', collectionFolderHint: '出于浏览器隐私保护，仅显示文件夹名称；授权过期时需要重新确认。', collectionFolderUnset: '未设置收藏文件夹', collectionFolderSelected: '{name}', collectionFolderNeedsPermission: '需要重新授权：{name}', selectFolder: '选择文件夹', changeFolder: '更改文件夹', refreshCollection: '刷新', backToCollection: '返回列表', collectionLoading: '正在读取收藏夹...', collectionEmpty: '收藏夹中暂无文本文件', collectionChooseFirst: '请先选择收藏文件夹', collectionUnsupported: '当前浏览器不支持选择收藏文件夹', collectionPermissionDenied: '收藏文件夹未授权，请在设置中重新选择', collectionUnavailable: '收藏文件夹已不可用，请重新选择', collectionReadOnly: '该文件夹不可写，请选择其他文件夹', collectionSaveFailed: '写入收藏文件失败', collectionLoadFailed: '读取收藏夹失败',
      startColor: '设置“开始总结”按钮颜色', paidMembers: '笃学会员、博学会员支持的设置', background: '背景', backgroundDepth: '背景深浅', defaultMode: '默认模式', nightMode: '夜间模式', readingMode: '阅读模式', naturalMode: '自然模式', autoScrollStreamingSummary: '输出总结时滚动条随着流式生成滚动',
      titleColor: '设置总结标题颜色', accentColor: '设置总结文字粗体颜色',
      detailed: '📚 详细无漏', standard: '📋 主次分明', concise: '🎯 简明扼要',
      creator: 'UP主', duration: '时长', unknown: '未知', characters: '字', subtitle: '字幕', summary: '总结',
      gettingSubtitle: '📖 正在获取字幕...', generating: '✍️ 正在生成总结...', generatingInSummary: '正在生成总结...', transcribingAudio: '正在提取音频并转写...', transcribingAudioProgress: '正在提取并上传音频... {percent}%', transcribingAudioFinalizing: '音频已上传，正在转写...', transcribingAudioSegments: '边上传边转写中 {done}/{total}...', audioUploadCaution: '上传时请勿切换分P或关闭侧栏，但可浏览其他标签页。', audioUploadedSafe: '音频已安全上传，可切换视频/分P或关闭侧栏；完成后会通知。', audioQueueBrowse: '可先浏览其他视频或网页；保持 Chrome 运行，完成后会通知。', done: '✅ 完成！', gettingVideoInfo: '正在获取视频信息...',
      send: '发送', answering: '回答中', askAboutSummary: '本总结 {count} 字，基于总结继续追问…', askAboutTranscript: '本字幕 {count} 字，基于字幕继续追问…',
      question: '追问', answer: '回答', generatingAnswer: '正在生成回答...', followUp: '追问', followUpLocked: '当前仅笃学会员、博学会员支持追问', followUpWaiting: '字幕加载完成后即可追问…', followUpNeedsSummary: '生成总结后可基于总结继续追问…', followUpCarryOn: '已携带字幕，点击取消携带', followUpCarryOff: '点击携带字幕一起\n追问，消耗会增加', followUpCarryEnabled: '已开启携带字幕一起追问，消耗会增加', followUpCarryDisabled: '已取消携带字幕', toolbox: '工具箱',
      myAccount: '我的', reload: '重新加载', start: '开始总结', login: '登录', register: '注册', usernameEmail: '用户名/邮箱', password: '密码', gender: '性别（注册后不可更改）', genderSelect: '请选择性别', genderMale: '♂ Male Sign', genderFemale: '♀ Female Sign', genderAlien: '👽 外星人头像', genderRequired: '请选择性别（注册后不可更改）', bookmark: '收藏', copy: '复制', summarizing: '正在总结', summarizeAgain: '再次总结',
      customStyle: '自定义风格提示词…最高可写200字', addCustomStyle: '添加自定义风格', deleteCustomStyle: '删除当前自定义风格', customStyleTooLong: '自定义风格最多 200 字，请精简后再添加', styleAssistLabel: '辅助生成', styleDirectAddLabel: '直接添加', assistStylePlaceholder: '不会写风格提示词？说说你想通过看视频学到、了解到些什么吧~我来帮你生成最直白、最不绕弯子、最客观、最系统、最真实的风格提示词 ^_^', assistStyleGenerate: '生成风格', assistStyleGenerating: '正在生成…', assistStyleSuccess: '已生成风格提示词，可编辑后点击添加', assistStyleTooLong: '内容太长了，请精简到 500 字以内', assistStyleEmpty: '请先写一点你看视频学到的东西，再点击生成风格',       collection: '收藏夹', watchLater: '稍后再看', watchLaterHint: '清理吃灰视频', toolboxTip: '这里有各种B站小功能', collectionTip: '这里有你保存的字幕与总结~', studyFlashcards: '记忆闪卡', studyQuiz: '知识测验', studyMindmap: '思维导图', studyFlashcardsTip: '从当前视频提取值得记住的知识点，通过主动回忆，帮助巩固记忆。', studyQuizTip: '根据当前视频重点自动生成测验，检查掌握情况\n并定位薄弱知识。', studyMindmapTip: '把字幕提炼成可展开收起的思维导图，层级结构一目\n了然，帮你把握全貌。', studyExperimentFree: '试验功能，只扣输入学分，免除输出学分', studyQinxueLimit: '勤学会员暂不支持20000字以上，长字幕可复制到deepseek总结', balance: '学分', estimateCredits: '预计消耗{credits}学分', subtitleSearch: '搜索字幕', subtitleSearchPlaceholder: '输入关键字搜索字幕…', subtitleSearchMatchCount: '共 {count} 处匹配', subtitleSearchNoMatch: '未找到匹配的字句', subtitleSearchClear: '清除', subtitleSearchClose: '关闭搜索', subtitleSearchJump: '跳转到 {time}', subtitleLocate: '定位到当前字幕', subtitleLocateTip: '定位到当前正在播放的字幕', subtitleLocateNoTimeline: '当前字幕没有时间轴，无法定位', playbackSpeed: '播放速度',
      styleRemaining: '{tier}本月还可以定义 {count} 次', styleLimit: '{tier}本月添加次数已用完，{date} 可再次定义',
      detailedPrompt: '请为以下视频内容创建详细笔记。按主题/时间段组织，包含所有重要细节、数据、例子和引用。适合复习使用。',
      standardPrompt: '请对以下视频内容进行全面总结，涵盖主要观点、关键信息和结论。保持客观准确，使用清晰的结构化格式。',
      concisePrompt: '请提取以下视频内容的核心要点，只列出最重要的观点和结论。每条要点用一句话概括，按重要性排序。',
    },
    'zh-TW': {
      settings: '設定', save: '儲存設定', allMembers: '所有會員皆可使用的設定', language: '顯示語言', displayScale: '側欄顯示比例', scaleSmall: '小', scaleStandard: '標準', scaleLarge: '大', enableFloatingButton: '開啟浮動按鈕快速開關側欄', disableRealtimePageSwitch: '切換網頁時不即時切換側欄', includeTranscriptInSummaryBookmark: '收藏（儲存）摘要時，是否附上原字幕', autoSaveSummaryAsTxt: '摘要完成即儲存為文字', saveTextsAsMarkdown: '所有文字儲存為 md 而非 txt（適合 Obsidian）', mdHelpCopyLabel: '複製問號裡的說明', mdHelpCopied: '說明已複製到剪貼簿', saveTextsAsMarkdownHint: 'Chrome 會把存檔標記為「來自網際網路」，開啟時才彈出安全提示，與 md/txt 格式無關。\n\n解除方法（擇一）：\n① 批次解除（建議）：按鍵盤 Win 鍵，輸入 powershell，按 Enter，開啟黑色視窗（PowerShell，Windows 內建）。把下列指令中的【你的收藏夾路徑】換成存檔資料夾的完整路徑（開啟資料夾後，從上方網址列複製），貼上後按 Enter：\nGet-ChildItem "你的收藏夾路徑" -Recurse -Include *.md,*.txt | Unblock-File\n看到指令正常執行、沒有紅色錯誤即成功，之後開啟檔案不再彈出提示。\n② 單一檔案：在檔案上按右鍵 → 內容 → 一般 → 勾選「解除鎖定」→ 確定。\n③ 或更換預設開啟方式：在檔案上按右鍵 → 開啟檔案 → 選擇 Obsidian 或記事本，勾選「一律使用」，之後開啟也不會再彈窗。', maxOutputTokens: '摘要、追問建議輸出的最大 Token', maxOutputTokensHint: '預設 3000；最好不超過此值，但內容完整優先，不會據此硬性截斷。', originalTranscript: '原字幕', history: '歷史', summaryHistory: '切換摘要歷史', emptySummaryHistory: '目前尚無已保留的影片摘要', historyHint: '保存最近 20 次摘要的結果，如需長存還請及時收藏。點擊即可跳轉到相應摘要，跳轉摘要後若需跳轉原影片則點擊頂部設定旁的影片標題。只要點擊過摘要，即使在未完成摘要時關閉側欄或切換到其他影片頁，摘要也會在伺服器端傳回摘要歷史，方便查閱❤️', historyHintCollapse: '收起', historyHintExpand: '展開', historyOpenSummary: '點擊查看該條摘要', historyOpenVideo: '跳轉到影片', historyDelete: '刪除該條摘要歷史', historyDeleted: '已刪除該條摘要歷史',
      collectionFolder: '收藏夾所在路徑', collectionFolderHint: '基於瀏覽器隱私保護，僅顯示資料夾名稱；授權過期時需要重新確認。', collectionFolderUnset: '尚未設定收藏資料夾', collectionFolderSelected: '{name}', collectionFolderNeedsPermission: '需要重新授權：{name}', selectFolder: '選擇資料夾', changeFolder: '更改資料夾', refreshCollection: '重新整理', backToCollection: '返回列表', collectionLoading: '正在讀取收藏夾...', collectionEmpty: '收藏夾中尚無文字檔案', collectionChooseFirst: '請先選擇收藏資料夾', collectionUnsupported: '目前瀏覽器不支援選擇收藏資料夾', collectionPermissionDenied: '收藏資料夾尚未授權，請在設定中重新選擇', collectionUnavailable: '收藏資料夾已無法使用，請重新選擇', collectionReadOnly: '此資料夾無法寫入，請選擇其他資料夾', collectionSaveFailed: '寫入收藏檔案失敗', collectionLoadFailed: '讀取收藏夾失敗',
      startColor: '設定「開始摘要」按鈕顏色', paidMembers: '篤學會員、博學會員支援的設定', background: '背景', backgroundDepth: '背景深淺', defaultMode: '預設模式', nightMode: '夜間模式', readingMode: '閱讀模式', naturalMode: '自然模式', autoScrollStreamingSummary: '輸出摘要時，捲動列隨串流內容自動捲動',
      titleColor: '設定摘要標題顏色', accentColor: '設定摘要粗體文字顏色',
      detailed: '📚 詳細筆記', standard: '📋 條理摘要', concise: '🎯 重點摘要',
      creator: '創作者', duration: '長度', unknown: '未知', characters: '字', subtitle: '逐字稿', summary: '摘要',
      gettingSubtitle: '📖 正在取得逐字稿...', generating: '✍️ 正在產生摘要...', generatingInSummary: '正在產生摘要...', transcribingAudio: '正在擷取音訊並轉寫...', transcribingAudioProgress: '正在擷取並上傳音訊... {percent}%', transcribingAudioFinalizing: '音訊已上傳，正在轉寫...', transcribingAudioSegments: '邊上傳邊轉寫中 {done}/{total}...', audioUploadCaution: '請勿切換影片/分P或關閉側欄；可另開分頁瀏覽。', audioUploadedSafe: '音訊已安全上傳，可切換影片/分P或關閉側欄；完成後會通知。', audioQueueBrowse: '可先瀏覽其他影片或網頁；保持 Chrome 執行，完成後會通知。', done: '✅ 完成！', gettingVideoInfo: '正在取得影片資訊...',
      send: '傳送', answering: '回答中', askAboutSummary: '本摘要 {count} 字，根據摘要繼續提問…', askAboutTranscript: '本逐字稿 {count} 字，根據逐字稿繼續提問…',
      question: '提問', answer: '回答', generatingAnswer: '正在產生回答...', followUp: '提問', followUpLocked: '目前僅篤學會員、博學會員支援提問', followUpWaiting: '逐字稿載入完成後即可提問…', followUpNeedsSummary: '產生總結後即可基於總結繼續提問…', followUpCarryOn: '已攜帶逐字稿，點擊取消攜帶', followUpCarryOff: '點擊攜帶逐字稿一起\n提問，消耗會增加', followUpCarryEnabled: '已開啟攜帶逐字稿一起提問，消耗會增加', followUpCarryDisabled: '已取消攜帶逐字稿', toolbox: '工具箱',
      myAccount: '我的', reload: '重新載入', start: '開始摘要', login: '登入', register: '註冊', usernameEmail: '使用者名稱／電子郵件', password: '密碼', gender: '性別（註冊後不可更改）', genderSelect: '請選擇性別', genderMale: '♂ Male Sign', genderFemale: '♀ Female Sign', genderAlien: '👽 外星人頭像', genderRequired: '請選擇性別（註冊後不可更改）', bookmark: '收藏', copy: '複製', summarizing: '正在產生摘要', summarizeAgain: '再次摘要',
      customStyle: '自訂風格提示詞…最多 200 字', addCustomStyle: '新增自訂風格', deleteCustomStyle: '刪除目前自訂風格', customStyleTooLong: '自訂風格最多 200 字，請精簡後再新增', styleAssistLabel: '輔助生成', styleDirectAddLabel: '直接新增', assistStylePlaceholder: '不會寫風格提示詞？說說你想透過看影片學到、了解到些什麼吧~我來幫你生成最直白、最不繞彎子、最客觀、最系統、最真實的風格提示詞 ^_^', assistStyleGenerate: '生成風格', assistStyleGenerating: '正在生成…', assistStyleSuccess: '已生成風格提示詞，可編輯後按新增', assistStyleTooLong: '內容太長了，請精簡到 500 字以內', assistStyleEmpty: '請先寫一點你看影片學到的東西，再點擊生成風格',       collection: '收藏夾', watchLater: '稍後再看', watchLaterHint: '清理吃灰影片', toolboxTip: '這裡有各種 B站小功能', collectionTip: '這裡有你儲存的逐字稿與摘要~', studyFlashcards: '記憶閃卡', studyQuiz: '知識測驗', studyMindmap: '思維導圖', studyFlashcardsTip: '從目前影片提取值得記住的知識點，透過主動回憶，幫助鞏固記憶。', studyQuizTip: '根據目前影片重點自動產生測驗，檢查掌握情況\n並定位薄弱知識。', studyMindmapTip: '把逐字稿提煉成可展開收起的思維導圖，層級結構一目\n瞭然，幫你把握全貌。', studyExperimentFree: '試驗功能，只扣輸入學分，免除輸出學分', studyQinxueLimit: '勤學會員暫不支援 20000 字以上，長字幕可複製到 DeepSeek 總結', balance: '學分', estimateCredits: '預計消耗{credits}學分', subtitleSearch: '搜尋逐字稿', subtitleSearchPlaceholder: '輸入關鍵字搜尋逐字稿…', subtitleSearchMatchCount: '共 {count} 處相符', subtitleSearchNoMatch: '找不到相符的字句', subtitleSearchClear: '清除', subtitleSearchClose: '關閉搜尋', subtitleSearchJump: '跳轉到 {time}', subtitleLocate: '定位到目前字幕', subtitleLocateTip: '定位到目前正在播放的字幕', subtitleLocateNoTimeline: '目前字幕沒有時間軸，無法定位', playbackSpeed: '播放速度',
      styleRemaining: '{tier}本月還可定義 {count} 次', styleLimit: '{tier}本月新增次數已用完，{date} 可再次定義',
      detailedPrompt: '請為下列影片內容建立詳細筆記，依主題或時間段整理，包含重要細節、數據、例子和引用，適合複習使用。',
      standardPrompt: '請以自然、清晰的繁體中文（臺灣用語）完整摘要下列影片內容，涵蓋主要觀點、重要資訊與結論，並採用條理分明的格式。',
      concisePrompt: '請擷取下列影片內容的核心重點，只列出最重要的觀點與結論；每一點以一句話說明，依重要性排序。',
    },
    en: {
      settings: 'Settings', save: 'Save settings', allMembers: 'Settings available to all members', language: 'Display language', displayScale: 'Side panel scale', scaleSmall: 'Small', scaleStandard: 'Standard', scaleLarge: 'Large', enableFloatingButton: 'Enable the floating button to quickly toggle the side panel', disableRealtimePageSwitch: 'Do not switch the side panel when changing pages', includeTranscriptInSummaryBookmark: 'Include the original transcript when saving a summary', autoSaveSummaryAsTxt: 'Save completed summaries as text', saveTextsAsMarkdown: 'Save all texts as Markdown (.md) instead of TXT (for Obsidian)', mdHelpCopyLabel: 'Copy the instructions from the question mark', mdHelpCopied: 'Instructions copied to clipboard', saveTextsAsMarkdownHint: 'Chrome marks saved files as "from the Internet", so a security prompt appears when opening them; this is unrelated to the md/txt extension. To unblock (pick one):\n\n① Bulk (recommended): press the Win key, type powershell, press Enter to open the black PowerShell window (built into Windows). Replace [YOUR COLLECTION FOLDER PATH] below with the full path of your folder (open the folder and copy it from the address bar), paste and press Enter:\nGet-ChildItem "[YOUR COLLECTION FOLDER PATH]" -Recurse -Include *.md,*.txt | Unblock-File\nIf the command completes without red errors, the files are unblocked and will no longer prompt.\n② Single file: right-click the file → Properties → General → check "Unblock" → OK.\n③ Or change the default app: right-click → Open with → choose Obsidian or Notepad and check "Always use this app" — no more prompts either.', maxOutputTokens: 'Maximum summary/follow-up output tokens', maxOutputTokensHint: 'Default: 3,000; preferably stay near this value, but completeness takes priority and replies are not hard-truncated.', originalTranscript: 'Original transcript', history: 'History', summaryHistory: 'Switch summary history', emptySummaryHistory: 'No saved video summaries yet', historyHint: 'Keeps your last 20 summaries. Click an item to open its summary; after opening, click the video title next to Settings at the top to open the original video. Once a summary has been started, even if you close the panel or switch videos before it finishes, the result is saved on the server and appears here for reference ❤️', historyHintCollapse: 'Collapse', historyHintExpand: 'Expand', historyOpenSummary: 'Click to open this summary', historyOpenVideo: 'Open the video', historyDelete: 'Delete this summary', historyDeleted: 'Summary deleted',
      collectionFolder: 'Collection folder', collectionFolderHint: 'For privacy, the browser only exposes the folder name. Access may need to be confirmed again later.', collectionFolderUnset: 'No collection folder selected', collectionFolderSelected: '{name}', collectionFolderNeedsPermission: 'Permission required: {name}', selectFolder: 'Select folder', changeFolder: 'Change folder', refreshCollection: 'Refresh', backToCollection: 'Back to list', collectionLoading: 'Loading collection...', collectionEmpty: 'No text files in this collection', collectionChooseFirst: 'Select a collection folder first', collectionUnsupported: 'This browser does not support collection folders', collectionPermissionDenied: 'Collection folder access is required. Select it again in Settings.', collectionUnavailable: 'The collection folder is unavailable. Select it again.', collectionReadOnly: 'This folder is not writable. Select another folder.', collectionSaveFailed: 'Could not save the collection file', collectionLoadFailed: 'Could not load the collection folder',
      startColor: 'Set “Start summary” button color', paidMembers: 'Settings for Duxue and Boxue members', background: 'Background', backgroundDepth: 'Background depth', defaultMode: 'Default', nightMode: 'Night', readingMode: 'Reading', naturalMode: 'Natural', autoScrollStreamingSummary: 'Follow streaming output while generating a summary',
      titleColor: 'Set summary title color', accentColor: 'Set bold text color',
      detailed: '📚 Detailed notes', standard: '📋 Structured summary', concise: '🎯 Key takeaways',
      creator: 'Creator', duration: 'Duration', unknown: 'Unknown', characters: 'characters', subtitle: 'Transcript', summary: 'Summary',
      gettingSubtitle: '📖 Getting transcript…', generating: '✍️ Generating summary…', generatingInSummary: 'Generating summary...', transcribingAudio: 'Extracting and transcribing audio...', transcribingAudioProgress: 'Extracting and uploading audio... {percent}%', transcribingAudioFinalizing: 'Audio uploaded. Transcribing...', transcribingAudioSegments: 'Transcribing while uploading {done}/{total}...', audioUploadCaution: 'Do not change the video/part or close the side panel; you may browse in another tab.', audioUploadedSafe: 'Audio is safely uploaded. You may change videos/parts or close the side panel; you will be notified when it finishes.', audioQueueBrowse: 'You may browse other videos or pages. Keep Chrome running to receive the completion notification.', done: '✅ Done!', gettingVideoInfo: 'Fetching video info…',
      send: 'Send', answering: 'Answering…', askAboutSummary: 'This summary has {count} characters. Ask a follow-up…', askAboutTranscript: 'This transcript has {count} characters. Ask a follow-up…',
      question: 'Question', answer: 'Answer', generatingAnswer: 'Generating answer...', followUp: 'Follow up', followUpLocked: 'Follow-up questions are available to Duxue and Boxue members', followUpWaiting: 'Follow-up will be available after the transcript loads…', followUpNeedsSummary: 'Follow-up based on the summary will be available after it is generated…', followUpCarryOn: 'Carrying the transcript. Click to stop.', followUpCarryOff: 'Click to carry the transcript\nalong; cost increases', followUpCarryEnabled: 'Carrying the transcript along; cost increases', followUpCarryDisabled: 'Transcript carry cancelled', toolbox: 'Toolbox',
      myAccount: 'Account', reload: 'Reload', start: 'Start summary', login: 'Log in', register: 'Sign up', usernameEmail: 'Username or email', password: 'Password', gender: 'Gender (cannot be changed after sign-up)', genderSelect: 'Choose a gender', genderMale: '♂ Male Sign', genderFemale: '♀ Female Sign', genderAlien: '👽 Alien avatar', genderRequired: 'Choose a gender (cannot be changed after sign-up)', bookmark: 'Save', copy: 'Copy', summarizing: 'Generating summary', summarizeAgain: 'Summarize again',
      customStyle: 'Custom style prompt… up to 200 characters', addCustomStyle: 'Add custom style', deleteCustomStyle: 'Delete current custom style', customStyleTooLong: 'Custom styles are limited to 200 characters. Please shorten it first.', styleAssistLabel: 'Assisted generation', styleDirectAddLabel: 'Add directly', assistStylePlaceholder: 'Not sure how to write a style prompt? Tell me what you want to learn from watching videos, and I will craft the most plain-spoken, straightforward, objective, systematic, and truthful style prompt for you ^_^', assistStyleGenerate: 'Generate', assistStyleGenerating: 'Generating…', assistStyleSuccess: 'Style prompt generated. You can edit it, then click Add.', assistStyleTooLong: 'Too long. Please keep it within 500 characters.', assistStyleEmpty: 'Tell me a bit about what you learned first, then click Generate.',       collection: 'Collection', watchLater: 'Watch Later', watchLaterHint: 'Clean up saved videos', toolboxTip: 'Handy tools for Bilibili videos', collectionTip: 'Your saved transcripts and summaries', studyFlashcards: 'Flashcards', studyQuiz: 'Quiz', studyMindmap: 'Mind Map', studyFlashcardsTip: 'Extract memorable knowledge points from the current video and consolidate them through active recall.', studyQuizTip: 'Generate a quiz from the video’s key points to check mastery and locate weak knowledge.', studyMindmapTip: 'Turn the transcript into an expandable mind map that reveals the structure at a glance.', studyExperimentFree: 'Experimental feature; only input credits are charged, output credits are exempt.', studyQinxueLimit: 'Qinxue does not support transcripts over 20,000 characters. Copy the transcript to DeepSeek to summarize.', balance: 'Credits', estimateCredits: 'Estimated cost: {credits} credits.', subtitleSearch: 'Search transcript', subtitleSearchPlaceholder: 'Search transcript by keyword…', subtitleSearchMatchCount: '{count} match(es)', subtitleSearchNoMatch: 'No matching lines found', subtitleSearchClear: 'Clear', subtitleSearchClose: 'Close search', subtitleSearchJump: 'Jump to {time}', subtitleLocate: 'Locate current subtitle', subtitleLocateTip: 'Jump to the subtitle currently playing', subtitleLocateNoTimeline: 'This transcript has no timeline, so it cannot be located', playbackSpeed: 'Playback speed',
      styleRemaining: '{tier} can create {count} more custom style(s) this month', styleLimit: '{tier} has used this month’s custom-style limit. Available again {date}.',
      detailedPrompt: 'Create detailed study notes from the video. Organize by topic or timeline and include important details, data, examples, and quotations.',
      standardPrompt: 'Create a thorough, accurate English summary of the video. Cover the main points, key information, and conclusions in a clear structure.',
      concisePrompt: 'Extract only the core takeaways from the video. Put the most important points first and express each in one concise sentence.',
    },
  };

  function normalizeLanguage(value) { return UI_TEXT[value] ? value : 'zh-CN'; }
  function t(key) { return UI_TEXT[currentLanguage]?.[key] || UI_TEXT['zh-CN'][key] || key; }
  function tf(key, values = {}) { return t(key).replace(/\{(\w+)\}/g, (_, name) => values[name] ?? ''); }
  // Display-only Simplified → Traditional (Taiwan) conversion. Keep source text
  // unchanged for subtitle matching, video identity checks, and model requests.
  const TW_PHRASES = [
    ['互联网', '網際網路'], ['网络', '網路'], ['视频', '影片'], ['字幕', '字幕'], ['软件', '軟體'],
    ['硬件', '硬體'], ['鼠标', '滑鼠'], ['鼠标', '滑鼠'], ['键盘', '鍵盤'], ['服务器', '伺服器'],
    ['程序', '程式'], ['文件', '檔案'], ['数据', '資料'], ['信息', '資訊'], ['默认', '預設'],
    ['保存', '儲存'], ['打印', '列印'], ['激光', '雷射'], ['博客', '部落格'], ['计算机', '電腦'],
    ['内存', '記憶體'], ['硬盘', '硬碟'], ['下载', '下載'], ['上传', '上傳'], ['账号', '帳號'],
    ['用户名', '使用者名稱'], ['用户', '使用者'], ['登录', '登入'], ['注册', '註冊'], ['头发', '頭髮'], ['开发', '開發'],
  ];
  const TW_CHARACTERS = {
    '万':'萬','与':'與','专':'專','业':'業','东':'東','丢':'丟','两':'兩','严':'嚴','个':'個','丰':'豐','临':'臨','为':'為','丽':'麗','举':'舉','么':'麼','义':'義','乌':'烏','乐':'樂','习':'習','乡':'鄉','书':'書','买':'買','乱':'亂','争':'爭','于':'於','亏':'虧','云':'雲','亚':'亞','产':'產','亩':'畝','亲':'親','亵':'褻','亿':'億','仅':'僅','从':'從','仓':'倉','仪':'儀','们':'們','优':'優','会':'會','伞':'傘','伟':'偉','传':'傳','伤':'傷','伦':'倫','伪':'偽','体':'體','余':'餘','侠':'俠','侥':'僥','侦':'偵','侧':'側','侨':'僑','侩':'儈','侬':'儂','债':'債','倾':'傾','偿':'償','储':'儲','儿':'兒','兑':'兌','党':'黨','兰':'蘭','关':'關','兴':'興','养':'養','兽':'獸','冈':'岡','册':'冊','写':'寫','军':'軍','农':'農','冲':'衝','冻':'凍','净':'淨','凉':'涼','减':'減','凤':'鳳','凭':'憑','凯':'凱','击':'擊','刘':'劉','则':'則','刚':'剛','创':'創','划':'劃','刘':'劉','删':'刪','别':'別','制':'製','剂':'劑','剑':'劍','剧':'劇','办':'辦','务':'務','动':'動','励':'勵','劲':'勁','劳':'勞','势':'勢','勋':'勳','区':'區','医':'醫','华':'華','协':'協','单':'單','卖':'賣','卫':'衛','却':'卻','厂':'廠','厅':'廳','历':'歷','厉':'厲','压':'壓','厌':'厭','厦':'廈','厨':'廚','县':'縣','参':'參','双':'雙','发':'發','变':'變','叙':'敘','叶':'葉','号':'號','叹':'嘆','听':'聽','启':'啟','吴':'吳','员':'員','问':'問','围':'圍','国':'國','图':'圖','圆':'圓','圣':'聖','场':'場','坏':'壞','块':'塊','坚':'堅','坛':'壇','垦':'墾','城':'城','复':'複','够':'夠','头':'頭','夹':'夾','夺':'奪','奋':'奮','奖':'獎','奥':'奧','妇':'婦','妈':'媽','学':'學','宁':'寧','宝':'寶','实':'實','审':'審','宫':'宮','宽':'寬','宾':'賓','寝':'寢','对':'對','导':'導','寿':'壽','将':'將','尘':'塵','尝':'嘗','层':'層','属':'屬','岗':'崗','岛':'島','峡':'峽','岭':'嶺','岳':'嶽','币':'幣','帅':'帥','师':'師','帐':'帳','帧':'幀','带':'帶','帮':'幫','干':'幹','广':'廣','庄':'莊','庆':'慶','庐':'廬','库':'庫','应':'應','庙':'廟','废':'廢','开':'開','异':'異','弃':'棄','张':'張','弥':'彌','弹':'彈','强':'強','归':'歸','录':'錄','彻':'徹','忆':'憶','忧':'憂','怀':'懷','态':'態','总':'總','恳':'懇','恶':'惡','恼':'惱','恽':'惲','悦':'悅','悬':'懸','惊':'驚','惨':'慘','惩':'懲','爱':'愛','戏':'戲','战':'戰','户':'戶','扩':'擴','扫':'掃','扬':'揚','换':'換','摇':'搖','摄':'攝','摆':'擺','敌':'敵','数':'數','斋':'齋','断':'斷','无':'無','旧':'舊','时':'時','显':'顯','晓':'曉','暂':'暫','术':'術','朴':'樸','机':'機','杀':'殺','杂':'雜','权':'權','条':'條','来':'來','杨':'楊','极':'極','构':'構','杰':'傑','松':'鬆','极':'極','标':'標','样':'樣','树':'樹','桥':'橋','梦':'夢','检':'檢','楼':'樓','欧':'歐','欢':'歡','岁':'歲','历':'歷','归':'歸','残':'殘','殴':'毆','毁':'毀','毕':'畢','气':'氣','汇':'匯','汉':'漢','汤':'湯','沟':'溝','没':'沒','泽':'澤','洁':'潔','浅':'淺','测':'測','济':'濟','浓':'濃','涛':'濤','渐':'漸','湾':'灣','湿':'濕','满':'滿','滤':'濾','滨':'濱','灾':'災','灯':'燈','灵':'靈','炉':'爐','点':'點','炼':'煉','热':'熱','爱':'愛','爷':'爺','爸':'爸','牵':'牽','状':'狀','独':'獨','猎':'獵','猫':'貓','献':'獻','环':'環','现':'現','玺':'璽','玻':'玻','电':'電','画':'畫','畅':'暢','疗':'療','监':'監','盖':'蓋','盘':'盤','矿':'礦','码':'碼','础':'礎','礼':'禮','祸':'禍','离':'離','种':'種','积':'積','称':'稱','稳':'穩','穷':'窮','简':'簡','签':'簽','篮':'籃','类':'類','粘':'黏','粮':'糧','红':'紅','约':'約','级':'級','纪':'紀','纯':'純','纸':'紙','纹':'紋','纳':'納','纵':'縱','纷':'紛','终':'終','组':'組','细':'細','织':'織','经':'經','结':'結','给':'給','绝':'絕','统':'統','绣':'繡','继':'繼','续':'續','绿':'綠','维':'維','纲':'綱','网':'網','罗':'羅','罚':'罰','美':'美','职':'職','联':'聯','聪':'聰','肃':'肅','肠':'腸','肤':'膚','脑':'腦','脚':'腳','脸':'臉','脱':'脫','脏':'髒','兴':'興','舰':'艦','艳':'豔','艺':'藝','节':'節','苏':'蘇','范':'範','药':'藥','蓝':'藍','虑':'慮','虚':'虛','虫':'蟲','虽':'雖','补':'補','袭':'襲','装':'裝','见':'見','观':'觀','规':'規','觉':'覺','览':'覽','触':'觸','订':'訂','计':'計','认':'認','讨':'討','让':'讓','训':'訓','议':'議','记':'記','讲':'講','许':'許','论':'論','设':'設','访':'訪','证':'證','评':'評','识':'識','诈':'詐','诊':'診','词':'詞','译':'譯','试':'試','诗':'詩','诚':'誠','话':'話','询':'詢','该':'該','详':'詳','语':'語','误':'誤','说':'說','请':'請','诸':'諸','读':'讀','课':'課','谁':'誰','调':'調','谈':'談','谋':'謀','谢':'謝','谱':'譜','负':'負','财':'財','贫':'貧','货':'貨','质':'質','贩':'販','贮':'貯','贰':'貳','贵':'貴','贷':'貸','费':'費','贺':'賀','资':'資','赋':'賦','购':'購','赛':'賽','赞':'讚','赢':'贏','赵':'趙','赶':'趕','趋':'趨','跃':'躍','车':'車','轨':'軌','转':'轉','轻':'輕','轮':'輪','软':'軟','载':'載','较':'較','辉':'輝','辑':'輯','输':'輸','办':'辦','辞':'辭','边':'邊','辽':'遼','达':'達','迁':'遷','选':'選','遗':'遺','遥':'遙','邮':'郵','邻':'鄰','郑':'鄭','释':'釋','里':'裡','鉴':'鑑','钟':'鐘','铁':'鐵','铃':'鈴','铸':'鑄','铺':'鋪','链':'鏈','锁':'鎖','锅':'鍋','键':'鍵','镇':'鎮','镜':'鏡','长':'長','门':'門','闭':'閉','问':'問','间':'間','闷':'悶','阅':'閱','队':'隊','阶':'階','阳':'陽','阴':'陰','阵':'陣','际':'際','陆':'陸','随':'隨','隐':'隱','难':'難','杂':'雜','鸡':'雞','离':'離','云':'雲','雾':'霧','电':'電','静':'靜','顶':'頂','顺':'順','须':'須','顾':'顧','顿':'頓','颁':'頒','预':'預','领':'領','题':'題','颜':'顏','风':'風','飞':'飛','饭':'飯','饮':'飲','饼':'餅','馆':'館','马':'馬','驱':'驅','验':'驗','骑':'騎','骗':'騙','骚':'騷','鱼':'魚','鲜':'鮮','鸟':'鳥','鸣':'鳴','鸿':'鴻','黄':'黃','点':'點','齐':'齊','龙':'龍'
  };
  TW_CHARACTERS['后'] = '後';
  function toDisplayLanguage(value) {
    const text = String(value ?? '');
    if (currentLanguage !== 'zh-TW' || !text) return text;
    const phrased = TW_PHRASES.reduce((result, [from, to]) => result.replaceAll(from, to), text);
    return phrased.replace(/[\u3400-\u9fff]/g, (char) => TW_CHARACTERS[char] || char);
  }
  const COLOR_PALETTE = [
    { color: '#18191c', name: '经典黑', dark: true },
    { color: '#fb7299', name: 'B站粉' },
    { color: '#00a1d6', name: 'B站蓝' },
    { color: '#ff6b6b', name: '珊瑚红' },
    { color: '#ffa94d', name: '活力橙' },
    { color: '#ffd43b', name: '柠檬黄' },
    { color: '#51cf66', name: '薄荷绿' },
    { color: '#845ef7', name: '梦幻紫' },
    { color: '#339af0', name: '天空蓝' },
    { color: '#20c997', name: '青翠' },
    { color: '#e599f7', name: '樱花粉' },
    { color: '#495057', name: '高级灰' },
  ];
  const COLOR_NAMES = {
    'zh-CN': ['经典黑', 'B站粉', 'B站蓝', '珊瑚红', '活力橙', '柠檬黄', '薄荷绿', '梦幻紫', '天空蓝', '青翠', '樱花粉', '高级灰'],
    'zh-TW': ['經典黑', 'B站粉', 'B站藍', '珊瑚紅', '活力橘', '檸檬黃', '薄荷綠', '夢幻紫', '天空藍', '青翠', '櫻花粉', '高級灰'],
    en: ['Classic black', 'Bilibili pink', 'Bilibili blue', 'Coral red', 'Vibrant orange', 'Lemon yellow', 'Mint green', 'Dream purple', 'Sky blue', 'Emerald', 'Cherry blossom pink', 'Slate gray'],
  };

  const els = {
    btnToolbox: $('#btn-toolbox'),
    btnCollection: $('#btn-collection'),
    btnStudyFlashcards: $('#btn-study-flashcards'),
    btnStudyQuiz: $('#btn-study-quiz'),
    btnStudyMindmap: $('#btn-study-mindmap'),
    studyFlashcardsTooltip: $('#study-flashcards-tooltip'),
    studyQuizTooltip: $('#study-quiz-tooltip'),
    studyMindmapTooltip: $('#study-mindmap-tooltip'),
    btnSettings: $('#btn-settings'),
    videoInfo: $('#video-info'),
    videoThumb: $('#video-thumb'),
    videoTitle: $('#video-title'),
    videoOwner: $('#video-owner'),
    videoDuration: $('#video-duration'),
    videoPage: $('#video-page'),
    statusBar: $('#status-bar'),
    statusIcon: $('#status-icon'),
    statusText: $('#status-text'),
    statusClose: $('#status-close'),
    advancedNotice: $('#advanced-notice'),
    advancedNoticeIcon: $('#advanced-notice-icon'),
    advancedNoticeTitle: $('#advanced-notice-title'),
    advancedNoticeContent: $('#advanced-notice-content'),
    advancedNoticeAction: $('#advanced-notice-action'),
    advancedNoticeClose: $('#advanced-notice-close'),
    controls: $('#controls'),
    styleSelect: $('#style-select'),
    btnManageStyles: $('#btn-manage-styles'),
    btnPlaybackSpeed: $('#btn-playback-speed'),
    playbackSpeedPopover: $('#playback-speed-popover'),
    assistPromptInput: $('#assist-prompt-input'),
    btnAssistStyle: $('#btn-assist-style'),
    customPromptInput: $('#custom-prompt-input'),
    btnAddStyle: $('#btn-add-style'),
    customStyleList: $('#custom-style-list'),
    styleManagerPanel: $('#style-manager-panel'),
    btnCloseStyleManager: $('#btn-close-style-manager'),
    estimatedCreditValue: $('#estimated-credit-value'),
    btnStart: $('#btn-start'),
    btnStartSlow: $('#btn-start-slow'),
    btnStartFast: $('#btn-start-fast'),
    btnStartKimi: $('#btn-start-kimi'),
    startSummaryWrap: $('#start-summary-wrap'),
    startCreditTooltip: $('#start-credit-tooltip'),
    audioSummaryActions: $('#audio-summary-actions'),
    slowSummaryTooltip: $('#slow-summary-tooltip'),
    kimiSummaryTooltip: $('#kimi-summary-tooltip'),
    fastSummaryTooltip: $('#fast-summary-tooltip'),
    progress: $('#progress'),
    progressFill: $('#progress-fill'),
    progressText: $('#progress-text'),
    result: $('#result'),
    resultTabs: $('#result-tabs'),
    resultContent: $('#result-content'),
    subtitleView: $('#subtitle-view'),
    summaryView: $('#summary-view'),
    subtitleSearchBtn: $('#btn-subtitle-search'),
    subtitleLocateBtn: $('#btn-subtitle-locate'),
    resultFontSmallerBtn: $('#btn-result-font-smaller'),
    resultFontLargerBtn: $('#btn-result-font-larger'),
    subtitleSearchBar: $('#subtitle-search-bar'),
    subtitleSearchInput: $('#subtitle-search-input'),
    btnSubtitleSearchClose: $('#btn-subtitle-search-close'),
    followUpDock: $('#followup-dock'),
    followUpInput: $('#followup-input'),
    followUpSubmit: $('#followup-submit'),
    followUpAttach: $('#followup-attach'),
    followUpAttachTip: $('#followup-attach-tip'),
    maxOutputTokens: null,
    floatingButtonEnabled: null,
    disableRealtimePageSwitch: null,
    autoSaveSummaryAsTxt: null,
    saveTextsAsMarkdown: null,
    autoScrollStreamingSummary: null,
    sidebarBackgroundMode: null,
    sidebarBackgroundDepth: null,
    btnBookmark: $('#btn-bookmark'),
    btnShareComment: $('#btn-share-comment'),
    summaryCharCount: $('#summary-char-count'),
    btnCopy: $('#btn-copy'),
    settingsPanel: $('#settings-panel'),
    btnCloseSettings: $('#btn-close-settings'),
    collectionPanel: $('#collection-panel'),
    btnCloseCollection: $('#btn-close-collection'),
    btnChooseCollectionFolder: $('#btn-choose-collection-folder'),
    btnRefreshCollection: $('#btn-refresh-collection'),
    btnBackCollection: $('#btn-back-collection'),
    collectionBrowserFolder: $('#collection-browser-folder'),
    collectionList: $('#collection-list'),
    collectionPreview: $('#collection-preview'),
    collectionPreviewTitle: $('#collection-preview-title'),
    collectionPreviewContent: $('#collection-preview-content'),
    startColorPalette: $('#start-color-palette'),
    titleColorPalette: $('#title-color-palette'),
    colorPalette: $('#color-palette'),
    toastContainer: $('#toast-container'),
    btnServer: $('#btn-server'),
    btnHistory: $('#btn-history'),
    btnEmptyServer: $('#btn-empty-server'),
    emptyNotificationBadge: $('#empty-notification-badge'),
    emptyNotificationTip: $('#empty-notification-tip'),
    emptyActions: $('#empty-actions'),
    historyPanel: $('#history-panel'),
    btnCloseHistory: $('#btn-close-history'),
    historyList: $('#history-list'),
    historyHint: $('#history-hint'),
    historyHintText: $('#history-hint-text'),
    btnToggleHistoryHint: $('#btn-toggle-history-hint'),
    toolboxPanel: $('#toolbox-panel'),
    btnCloseToolbox: $('#btn-close-toolbox'),
    toolboxFolder: $('#toolbox-folder'),
    btnDownloadCover: $('#btn-download-cover'),
    btnDownloadComments: $('#btn-download-comments'),
    btnDownloadAudio: $('#btn-download-audio'),
    btnDownloadAllAudio: $('#btn-download-all-audio'),
    btnDownloadVideo: $('#btn-download-video'),
    btnDownloadAllVideo: $('#btn-download-all-video'),
    toolboxAudioFormat: $('#toolbox-audio-format'),
    toolboxVideoQuality: $('#toolbox-video-quality'),
    toolboxDownloadStatus: $('#toolbox-download-status'),
    toolboxDownloadMessage: $('#toolbox-download-message'),
    toolboxDownloadPercent: $('#toolbox-download-percent'),
    toolboxDownloadProgressFill: $('#toolbox-download-progress-fill'),
    toolboxDownloadComplete: $('#toolbox-download-complete'),
    toolboxDownloadCompleteMessage: $('#toolbox-download-complete-message'),
    btnCloseDownloadComplete: $('#btn-close-download-complete'),
    toolboxFavorites: $('#toolbox-favorites'),
    btnMobileSummary: $('#btn-mobile-summary'),
    mobileSummaryTooltip: $('#mobile-summary-tooltip'),
    mobileSummaryPanel: $('#mobile-summary-panel'),
    btnCloseMobileSummary: $('#btn-close-mobile-summary'),
    mobileSummaryHow: $('#mobile-summary-how'),
    mobileSummaryServiceStatus: $('#mobile-summary-service-status'),
    mobileSummaryBoundText: $('#mobile-summary-bound-text'),
    btnFollowBot: $('#btn-follow-bot'),
    btnDetectUid: $('#btn-detect-uid'),
    btnManualUid: $('#btn-manual-uid'),
    mobileSummaryManualRow: $('#mobile-summary-manual-row'),
    mobileSummaryUidInput: $('#mobile-summary-uid-input'),
    btnSaveManualUid: $('#btn-save-manual-uid'),
    mobileSummaryUidHint: $('#mobile-summary-uid-hint'),
    btnOpenStudyAgent: $('#btn-open-study-agent'),
    btnOsIos: $('#btn-os-ios'),
    btnOsAndroid: $('#btn-os-android'),
    osIosCount: $('#os-ios-count'),
    osAndroidCount: $('#os-android-count'),
    mobileSummaryVoteBar: $('#mobile-summary-vote-bar'),
    voteBarIos: $('#vote-bar-ios'),
    voteBarAndroid: $('#vote-bar-android'),
    mobileAppProgress: $('#mobile-app-progress'),
    mobileAppProgressFill: $('#mobile-app-progress-fill'),
    mobileAppProgressNote: $('#mobile-app-progress-note'),
    mobileAppProgressLink: $('#mobile-app-progress-link'),
    mobileSummaryModal: $('#mobile-summary-modal'),
    btnCloseMobileSummaryModal: $('#btn-close-mobile-summary-modal'),
    shareCommentWrap: $('#share-comment-wrap'),
    shareCommentTooltip: $('#share-comment-tooltip'),
    notificationBadge: $('#notification-badge'),
    notificationTip: $('#notification-tip'),
  };

  let currentTab = null;
  let videoData = null;
  let isProcessing = false;
  let isAudioTranscribing = false;
  let slowAsrQueued = false;
  let isSubtitleLoading = false;
  let lastSummary = '';
  let lastSubtitleText = '';
  let subtitleSegments = [];
  let lastSubtitleWasTranscribedAudio = false;
  let lastSummaryTranscript = '';
  let subtitleResultMeta = null;
  let summaryResultMeta = null;
  let lastSummaryText = '';
  let currentResultMode = 'subtitle'; // 'summary' or 'subtitle'
  let estimatedSummaryCredits = 0;
  let currentCreditBalance = null;
  let currentAudioSummaryQuota = null; // 每日音频总结总时长额度（音频总结 + fast 合计）
  let followUpHistory = [];
  let isFollowUpProcessing = false;
  let clearedLegacySubtitleCache = false;
  let lastReportedSubtitleKey = '';
  let videoDetectionId = 0;
  let showSubtitleCharCount = true; // 字幕字数显示开关
  let currentUserTier = 'qinxue'; // 当前用户会员等级
  let currentLanguage = 'zh-CN';
  let includeTranscriptInSummaryBookmark = false;
  let floatingButtonEnabled = true;
  let disableRealtimePageSwitch = false;
  let autoSaveSummaryAsTxt = false;
  let saveTextsAsMarkdown = false;
  let autoScrollStreamingSummary = false;
  let sidebarBackgroundMode = 'default';
  let sidebarBackgroundDepth = 50;
  let maxOutputTokens = 3000;
  let subtitleFollowEnabled = false;
  let subtitleFollowTimer = null;
  let subtitleProgrammaticScroll = false;
  let subtitleSearchOpen = false;
  let subtitleSearchKeyword = '';
  let subtitleSearchMatches = [];
  let subtitleSearchActiveIndex = 0;
  let subtitleSearchFollowPaused = false;
  let displayScale = 'standard';
  let resultFontLevel = 0;
  let accountSettingsUserId = '';
  let mdHelpTooltipHide = null;
  let followUpExpanded = false;
  let carryTranscript = false;
  let collectionDirectoryHandle = null;
  let collectionDirectoryPermission = 'unknown';
  let toolboxBusy = false;
  let toolboxContextLoading = false;
  let toolboxQualityBvid = '';
  let toolboxAvailableQualities = [];
  let toolboxCollectionTitle = '';
  let toolboxCollectionEpisodes = [];
  let toolboxFavoritesCache = null;
  let subtitleViewCache = null;
  let playbackSpeed = 1;
  let subtitleProgressiveTimer = null;
  let subtitleRenderedCount = 0;
  let subtitleStaticLines = [];
  const mediaDownloadQueue = [];
  let activeMediaDownload = null;
  let studyActionBusy = false;
  let pendingFastAsrConfirmationKey = '';
  let kimiDownloadBusy = false;
  let activeKimiJobId = '';
  let kimiStatusWrite = Promise.resolve();
  const KIMI_COMPRESSION_NOTICE = '已在尽量帮您压缩，但仍有可能超过kimi限制';
  let settingsSaveQueue = Promise.resolve();
  let activeAdvancedNotice = null;
  let serverAdvancedNotice = null;
  const advancedNoticeClosedSources = new Set();
  const videoSessions = new Map();
  let activeVideoSession = null;
  let summaryHistory = [];
  // 历史（含服务器留存）加载完成前禁用开始总结/音频总结按钮，避免手快在
  // 历史尚未确认时重复发起总结；服务器回传卡住时由看门狗兜底放行，绝不长期锁死。
  let historyLoaded = false;
  let historyLoadingDepth = 0;
  let historyLoadingWatchdog = null;
  let historyHintCollapsed = false;
  let tabSwitchTimer = null;
  let summaryRunId = 0;
  let isInitializing = true; // 初始化标志，抑制加载时的toast
  const FOLLOW_UP_MAX_ATTEMPTS = 3;
  const STYLE_MAX_COUNT = 33; // 3 个内置风格 + 每月最多 30 个自定义风格
  const CUSTOM_STYLE_MAX_LENGTH = 200; // 单条自定义风格字数上限
  const SUMMARY_INPUT_CHAR_LIMIT = 100000; // 单次总结可处理的字幕字数上限
  const QINXUE_STUDY_CHAR_LIMIT = 20000;
  const SLOW_AUDIO_SUMMARY_MAX_DURATION_SECONDS = 60 * 60;
  const MAX_OUTPUT_TOKENS_DEFAULT = 3000;
  const MAX_OUTPUT_TOKENS_MIN = 100;
  const MAX_OUTPUT_TOKENS_MAX = 12000;
  const TOOLBOX_FAVORITES_CACHE_KEY = 'toolboxFavoritesCache';
  const TOOLBOX_FAVORITES_CACHE_TTL = 10 * 60 * 1000;
  const DISPLAY_SCALE_OPTIONS = ['small', 'standard', 'large'];
  const RESULT_FONT_LEVEL_KEY = 'biliSummaryResultFontLevel';
  const RESULT_FONT_LEVEL_MIN = -3;
  const RESULT_FONT_LEVEL_MAX = 3;
  const RESULT_FONT_BASE_SIZE_PX = 13;
  const PLAYBACK_SPEED_KEY = 'biliSummaryPlaybackSpeed';
  const ADVANCED_NOTICE_DISMISSED_KEY = 'biliSummaryAdvancedNoticeDismissed';
  const HISTORY_HINT_COLLAPSED_KEY = 'biliSummaryHistoryHintCollapsed';
  // 字幕分段渐进渲染：首屏只建前 5000 字对应的按钮，之后每隔几秒再追加
  // 一批，避免长字幕一次性创建上万个 DOM 节点导致侧栏卡顿。
  const SUBTITLE_BATCH_CHARS = 5000;
  const SUBTITLE_BATCH_INTERVAL_MS = 2000;
  const streamHandlers = new Map();
  chrome.runtime.connect({ name: 'bili-summary-side-panel' });

  // ============ 初始化 ============
  async function init() {
    isInitializing = true;
    buildSettingsLayout();
    buildCreditEstimateBox();
    setupEventListeners();
    await loadSettings();
    await loadSummaryHistory();
    await syncPendingSummaryResults();
    try {
      const storedLock = await chrome.storage.local.get(MOBILE_BETA_LOCKED_KEY);
      persistedMobileBetaLocked = storedLock[MOBILE_BETA_LOCKED_KEY] === true;
      renderMobileSummaryAccess();
    } catch (e) {}
    isInitializing = false;
    const detection = detectVideo({ waitForSubtitle: true });
    void announceSidePanelState(true);
    await Promise.allSettled([
      loadCollectionDirectoryHandle(),
      refreshMembershipAccess(),
      refreshNotificationBadge(),
      refreshAdvancedNotice(),
      notifyFailedAudioSummaryJobs(),
      detection,
    ]);
  }

  // ============ 设置读写 ============
  function paletteHtml() {
    return COLOR_PALETTE.map((item, index) => '<span class="color-swatch" data-color="' + item.color + '" style="background:' + item.color + (item.dark ? '; border:1px solid #ccc' : '') + '" title="' + (COLOR_NAMES[currentLanguage] || COLOR_NAMES['zh-CN'])[index] + '"></span>').join('');
  }

  function canCustomizeSummaryTextColors() {
    return currentUserTier === 'admin' || currentUserTier === 'duxue' || currentUserTier === 'mengxue';
  }

  function canBookmarkToFolder() {
    return currentUserTier === 'admin' || currentUserTier === 'duxue' || currentUserTier === 'mengxue';
  }

  function canUseToolboxPremiumDownload() {
    return currentUserTier === 'admin' || currentUserTier === 'duxue' || currentUserTier === 'mengxue';
  }

  function getDefaultColors(tier = currentUserTier) {
    if (tier === 'duxue' || tier === 'mengxue') {
      return { titleColor: '#fb7299', accentColor: '#00a1d6', startButtonColor: '#fb7299' };
    }
    return { titleColor: '#18191c', accentColor: '#18191c', startButtonColor: '#fb7299' };
  }

  function isPaletteColor(color) {
    return COLOR_PALETTE.some((item) => item.color === color);
  }

  function normalizeMaxOutputTokens(value) {
    const tokens = Math.round(Number(value));
    if (!Number.isFinite(tokens)) return MAX_OUTPUT_TOKENS_DEFAULT;
    return Math.min(MAX_OUTPUT_TOKENS_MAX, Math.max(MAX_OUTPUT_TOKENS_MIN, tokens));
  }

  function normalizeDisplayScale(value) {
    return DISPLAY_SCALE_OPTIONS.includes(String(value || '')) ? String(value) : 'standard';
  }

  function applyDisplayScale(value) {
    displayScale = normalizeDisplayScale(value);
    document.body.classList.toggle('ui-scale-small', displayScale === 'small');
    document.body.classList.toggle('ui-scale-large', displayScale === 'large');
    if (els.displayScale) els.displayScale.value = String(DISPLAY_SCALE_OPTIONS.indexOf(displayScale));
  }

  // ============ B站播放速度 ============
  function normalizePlaybackSpeed(value) {
    const rate = Number(value);
    return Number.isFinite(rate) && rate > 0 ? rate : 1;
  }

  function formatPlaybackSpeed(rate) {
    return 'X' + normalizePlaybackSpeed(rate).toFixed(2);
  }

  function updatePlaybackSpeedButton(rate) {
    playbackSpeed = normalizePlaybackSpeed(rate);
    if (els.btnPlaybackSpeed) {
      els.btnPlaybackSpeed.textContent = formatPlaybackSpeed(playbackSpeed);
      els.btnPlaybackSpeed.setAttribute('aria-label', formatPlaybackSpeed(playbackSpeed));
    }
    els.playbackSpeedPopover?.querySelectorAll('.playback-speed-grid button').forEach((button) => {
      button.classList.toggle('active', Math.abs(Number(button.dataset.rate) - playbackSpeed) < 1e-4);
    });
  }

  async function applyPlaybackSpeedToTab(tabId = currentTab?.id) {
    if (!tabId) return;
    // 扩展更新不会自动替换已经打开页面中的 MAIN world 脚本。每次调速前幂等注入
    // 当前桥版本，让旧页面也能移除曾经的全局 playbackRate setter，无需猜测页面版本。
    await chrome.runtime.sendMessage({ action: 'ensurePlaybackRateMainWorld', tabId }).catch(() => null);
    try {
      await chrome.tabs.sendMessage(tabId, { action: 'setPlaybackSpeed', rate: playbackSpeed });
    } catch (_) {
      // 页面内容脚本可能因扩展重载仍是旧版本，重新注入后补发一次。
      await chrome.runtime.sendMessage({ action: 'injectContentScript', tabId }).catch(() => null);
      await chrome.tabs.sendMessage(tabId, { action: 'setPlaybackSpeed', rate: playbackSpeed }).catch(() => {});
    }
  }

  function setPlaybackSpeed(rate) {
    const next = normalizePlaybackSpeed(rate);
    updatePlaybackSpeedButton(next);
    chrome.storage.local.set({ [PLAYBACK_SPEED_KEY]: next }).catch(() => {});
    applyPlaybackSpeedToTab();
  }

  function closePlaybackSpeedPopover() {
    if (!els.playbackSpeedPopover) return;
    els.playbackSpeedPopover.classList.add('hidden');
    els.btnPlaybackSpeed?.setAttribute('aria-expanded', 'false');
  }

  function togglePlaybackSpeedPopover() {
    if (!els.playbackSpeedPopover) return;
    const opening = els.playbackSpeedPopover.classList.contains('hidden');
    els.playbackSpeedPopover.classList.toggle('hidden', !opening);
    els.btnPlaybackSpeed?.setAttribute('aria-expanded', String(opening));
  }

  function normalizeSidebarBackgroundMode(value) {
    return ['default', 'night', 'reading', 'natural'].includes(String(value || '')) ? String(value) : 'default';
  }

  function normalizeSidebarBackgroundDepth(value) {
    const depth = Math.round(Number(value));
    return Number.isFinite(depth) ? Math.min(100, Math.max(0, depth)) : 50;
  }

  function mixColor(from, to, amount) {
    const parse = (color) => color.match(/[0-9a-f]{2}/gi).map((part) => parseInt(part, 16));
    const a = parse(from);
    const b = parse(to);
    return '#' + a.map((value, index) => Math.round(value + (b[index] - value) * amount).toString(16).padStart(2, '0')).join('');
  }

  function applySidebarAppearance(mode, depth) {
    const canCustomize = canCustomizeSummaryTextColors();
    sidebarBackgroundMode = normalizeSidebarBackgroundMode(mode);
    sidebarBackgroundDepth = normalizeSidebarBackgroundDepth(depth);
    const activeMode = canCustomize ? sidebarBackgroundMode : 'default';
    const ratio = sidebarBackgroundDepth / 100;
    const targets = { default: '#ffffff', night: '#101114', reading: '#f4e7b2', natural: '#cfe8cf' };
    const background = activeMode === 'night'
      ? mixColor('#2b2d33', targets.night, ratio)
      : mixColor('#ffffff', targets[activeMode], ratio);
    const dark = activeMode === 'night';
    const secondary = dark ? mixColor('#25262b', background, .72) : mixColor(background, '#f6f7f8', activeMode === 'default' ? 1 : .38);
    const border = dark ? '#3c3f46' : activeMode === 'default' ? '#c6ccd3' : mixColor(background, '#aab2ba', .64);
    document.body.classList.toggle('dark-mode', dark);
    document.body.classList.toggle('alt-bg', activeMode !== 'default');
    document.documentElement.style.setProperty('--bg-primary', background);
    document.documentElement.style.setProperty('--bg-secondary', secondary);
    document.documentElement.style.setProperty('--text-primary', dark ? '#f1f2f3' : '#18191c');
    document.documentElement.style.setProperty('--text-secondary', dark ? '#c6c9ce' : '#61666d');
    document.documentElement.style.setProperty('--text-muted', dark ? '#92979f' : '#9499a0');
    document.documentElement.style.setProperty('--border-color', border);
    document.documentElement.style.setProperty('--hover-blue-bg', dark ? '#183846' : mixColor(background, '#e6f7ff', .72));
    document.documentElement.style.setProperty('--hover-pink-bg', dark ? '#422933' : mixColor(background, '#fff0f3', .72));
    document.documentElement.style.setProperty('--error-bg', dark ? '#442629' : mixColor(background, '#fff2f0', .72));
    if (els.sidebarBackgroundMode) {
      els.sidebarBackgroundMode.querySelectorAll('button').forEach((button) => {
        button.classList.toggle('active', button.dataset.mode === sidebarBackgroundMode);
      });
    }
    if (els.sidebarBackgroundDepth) els.sidebarBackgroundDepth.value = String(sidebarBackgroundDepth);
    // 背景深浅滑动条只在非默认模式下才有意义，默认模式隐藏以免误以为无效
    const depthGroup = els.sidebarBackgroundDepth?.closest('.background-depth-setting');
    if (depthGroup) depthGroup.classList.toggle('hidden', activeMode === 'default');
  }

  function buildSettingsLayout() {
    const body = els.settingsPanel?.querySelector('.settings-body');
    const titleGroup = els.titleColorPalette?.closest('.control-group');
    const accentGroup = els.colorPalette?.closest('.control-group');
    if (!body || !titleGroup || !accentGroup) return;

    titleGroup.querySelector('.control-label').textContent = t('titleColor');
    accentGroup.querySelector('.control-label').textContent = t('accentColor');
    els.titleColorPalette.innerHTML = paletteHtml();
    els.colorPalette.innerHTML = paletteHtml();

    let startGroup = document.getElementById('start-color-palette')?.closest('.control-group');
    if (!startGroup) {
      startGroup = document.createElement('div');
      startGroup.className = 'control-group';
      startGroup.innerHTML = ''
        + '<label class="control-label">' + t('startColor') + '</label>'
        + '<div class="color-palette" id="start-color-palette">' + paletteHtml() + '</div>';
    }
    startGroup.querySelector('.control-label').textContent = t('startColor');

    const allMemberSection = document.createElement('div');
    allMemberSection.className = 'settings-section';
    allMemberSection.innerHTML = '<div class="settings-section-title">' + t('allMembers') + '</div>';
    const languageGroup = document.createElement('div');
    languageGroup.className = 'control-group';
    languageGroup.innerHTML = '<label class="control-label" for="display-language">' + DISPLAY_LANGUAGE_LABEL + '</label>'
      + '<select id="display-language" class="input-field">' + LANGUAGE_OPTIONS.map(([value, label]) => '<option value="' + value + '">' + label + '</option>').join('') + '</select>';
    allMemberSection.appendChild(languageGroup);
    const collectionFolderGroup = document.createElement('div');
    collectionFolderGroup.className = 'control-group collection-folder-setting';
    collectionFolderGroup.innerHTML = '<label class="control-label" for="btn-select-collection-folder">' + t('collectionFolder') + '</label>'
      + '<div class="collection-folder-setting-row">'
      + '<div id="collection-folder-status" class="collection-folder-status" role="status"></div>'
      + '<button id="btn-select-collection-folder" class="btn-secondary collection-folder-button" type="button"></button>'
      + '</div>'
      + '<div class="collection-folder-hint">' + t('collectionFolderHint') + '</div>';
    allMemberSection.appendChild(collectionFolderGroup);
    const maxOutputTokensGroup = document.createElement('div');
    maxOutputTokensGroup.className = 'control-group followup-length-group';
    maxOutputTokensGroup.innerHTML = '<label class="control-label" for="max-output-tokens">' + t('maxOutputTokens') + '</label>'
      + '<input id="max-output-tokens" class="input-field" type="number" min="' + MAX_OUTPUT_TOKENS_MIN + '" max="' + MAX_OUTPUT_TOKENS_MAX + '" step="100" inputmode="numeric">'
      + '<div class="followup-length-hint">' + t('maxOutputTokensHint') + '</div>';
    allMemberSection.appendChild(maxOutputTokensGroup);
    const floatingButtonGroup = document.createElement('div');
    floatingButtonGroup.className = 'control-group';
    floatingButtonGroup.innerHTML = '<label class="checkbox-label settings-checkbox-label" for="floating-button-enabled">'
      + '<input id="floating-button-enabled" type="checkbox">'
      + '<span>' + t('enableFloatingButton') + '</span></label>';
    allMemberSection.appendChild(floatingButtonGroup);
    const pageSwitchGroup = document.createElement('div');
    pageSwitchGroup.className = 'control-group';
    pageSwitchGroup.innerHTML = '<label class="checkbox-label settings-checkbox-label" for="disable-realtime-page-switch">'
      + '<input id="disable-realtime-page-switch" type="checkbox">'
      + '<span>' + t('disableRealtimePageSwitch') + '</span></label>';
    allMemberSection.appendChild(pageSwitchGroup);
    const displayScaleGroup = document.createElement('div');
    displayScaleGroup.className = 'control-group display-scale-control';
    displayScaleGroup.innerHTML = '<label class="control-label" for="display-scale">' + t('displayScale') + '</label>'
      + '<input id="display-scale" class="display-scale-slider" type="range" min="0" max="2" step="1" value="1">'
      + '<div class="display-scale-labels"><span>' + t('scaleSmall') + '</span><span>' + t('scaleStandard') + '</span><span>' + t('scaleLarge') + '</span></div>';
    allMemberSection.appendChild(displayScaleGroup);
    allMemberSection.appendChild(startGroup);

    const paidMemberSection = document.createElement('div');
    paidMemberSection.className = 'settings-section';
    paidMemberSection.id = 'summary-text-color-settings';
    paidMemberSection.innerHTML = '<div class="settings-section-title">' + t('paidMembers') + '</div>';
    paidMemberSection.appendChild(titleGroup);
    paidMemberSection.appendChild(accentGroup);
    const backgroundModeGroup = document.createElement('div');
    backgroundModeGroup.className = 'control-group paid-theme-setting';
    backgroundModeGroup.innerHTML = '<label class="control-label">' + t('background') + '</label>'
      + '<div id="sidebar-background-mode" class="background-mode-options" role="group">'
      + [['default', 'defaultMode'], ['night', 'nightMode'], ['reading', 'readingMode'], ['natural', 'naturalMode']]
        .map(([mode, label]) => '<button type="button" data-mode="' + mode + '">' + t(label) + '</button>').join('')
      + '</div>';
    paidMemberSection.appendChild(backgroundModeGroup);
    const backgroundDepthGroup = document.createElement('div');
    backgroundDepthGroup.className = 'control-group paid-theme-setting background-depth-setting';
    backgroundDepthGroup.innerHTML = '<label class="control-label" for="sidebar-background-depth">' + t('backgroundDepth') + '</label>'
      + '<input id="sidebar-background-depth" class="background-depth-slider" type="range" min="0" max="100" step="1">';
    paidMemberSection.appendChild(backgroundDepthGroup);
    const streamScrollGroup = document.createElement('div');
    streamScrollGroup.className = 'control-group paid-stream-scroll-setting';
    streamScrollGroup.innerHTML = '<label class="checkbox-label settings-checkbox-label" for="auto-scroll-streaming-summary">'
      + '<input id="auto-scroll-streaming-summary" type="checkbox">'
      + '<span>' + t('autoScrollStreamingSummary') + '</span></label>';
    paidMemberSection.appendChild(streamScrollGroup);
    const bookmarkContentGroup = document.createElement('div');
    bookmarkContentGroup.className = 'control-group paid-bookmark-content-setting';
    bookmarkContentGroup.innerHTML = '<label class="checkbox-label settings-checkbox-label" for="include-transcript-in-summary-bookmark">'
      + '<input id="include-transcript-in-summary-bookmark" type="checkbox">'
      + '<span>' + t('includeTranscriptInSummaryBookmark') + '</span></label>';
    paidMemberSection.appendChild(bookmarkContentGroup);
    const autoSaveSummaryGroup = document.createElement('div');
    autoSaveSummaryGroup.className = 'control-group paid-auto-save-setting';
    autoSaveSummaryGroup.innerHTML = '<label class="checkbox-label settings-checkbox-label" for="auto-save-summary-as-txt">'
      + '<input id="auto-save-summary-as-txt" type="checkbox">'
      + '<span>' + t('autoSaveSummaryAsTxt') + '</span></label>';
    paidMemberSection.appendChild(autoSaveSummaryGroup);
    const saveAsMarkdownGroup = document.createElement('div');
    saveAsMarkdownGroup.className = 'control-group paid-save-as-markdown-setting';
    saveAsMarkdownGroup.innerHTML = '<label class="checkbox-label settings-checkbox-label" for="save-texts-as-markdown">'
      + '<input id="save-texts-as-markdown" type="checkbox">'
      + '<span>' + t('saveTextsAsMarkdown') + '</span>'
      + '<span class="md-help-wrap">'
      + '<span class="md-help-mark" aria-hidden="true">?</span>'
      + '<button type="button" class="md-help-copy" title="' + t('mdHelpCopyLabel') + '" aria-label="' + t('mdHelpCopyLabel') + '">复</button>'
      + '<span id="save-texts-as-markdown-tip" class="md-help-tooltip" role="tooltip">' + t('saveTextsAsMarkdownHint') + '</span>'
      + '</span></label>';
    saveAsMarkdownGroup.querySelector('.md-help-wrap')?.addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();
    });
    const mdHelpWrap = saveAsMarkdownGroup.querySelector('.md-help-wrap');
    const mdHelpMark = mdHelpWrap?.querySelector('.md-help-mark');
    const mdHelpTip = saveAsMarkdownGroup.querySelector('#save-texts-as-markdown-tip');
    if (mdHelpWrap && mdHelpMark && mdHelpTip) {
      const mdHelpCopyBtn = mdHelpWrap.querySelector('.md-help-copy');
      if (mdHelpCopyBtn) {
        mdHelpCopyBtn.addEventListener('click', (e) => {
          e.preventDefault();
          e.stopPropagation();
          const hintText = mdHelpTip.textContent || '';
          copyTextToClipboard(hintText)
            .then(() => showToast(t('mdHelpCopied'), 'success'))
            .catch(() => showToast(t('mdHelpCopied'), 'error'));
        });
      }
      const showMdHelpTooltip = () => {
        const container = document.querySelector('.container');
        if (!container) return;
        const cRect = container.getBoundingClientRect();
        const scale = container.offsetWidth ? cRect.width / container.offsetWidth : 1;
        mdHelpTip.classList.add('md-help-tooltip-visible');
        const markRect = mdHelpMark.getBoundingClientRect();
        const tipRect = mdHelpTip.getBoundingClientRect();
        const tipW = tipRect.width / scale;
        const tipH = tipRect.height / scale;
        const cw = cRect.width / scale;
        const ch = cRect.height / scale;
        let left = (markRect.left + markRect.width / 2 - tipW / 2) / scale;
        left = Math.max(4, Math.min(left, cw - tipW - 4));
        let top = (markRect.bottom + 8) / scale;
        if (top + tipH > ch - 4) top = Math.max(4, markRect.top / scale - 8 - tipH);
        mdHelpTip.style.left = left + 'px';
        mdHelpTip.style.top = top + 'px';
      };
      const hideMdHelpTooltip = () => mdHelpTip.classList.remove('md-help-tooltip-visible');
      mdHelpWrap.addEventListener('mouseenter', showMdHelpTooltip);
      mdHelpWrap.addEventListener('mouseleave', hideMdHelpTooltip);
      if (mdHelpTooltipHide) els.settingsPanel?.removeEventListener('scroll', mdHelpTooltipHide);
      mdHelpTooltipHide = hideMdHelpTooltip;
      els.settingsPanel?.addEventListener('scroll', mdHelpTooltipHide, { passive: true });
    }
    paidMemberSection.appendChild(saveAsMarkdownGroup);

    const divider = document.createElement('hr');
    divider.className = 'settings-section-divider';

    body.textContent = '';
    body.appendChild(allMemberSection);
    body.appendChild(divider);
    body.appendChild(paidMemberSection);
    els.startColorPalette = document.getElementById('start-color-palette');
    els.displayLanguage = document.getElementById('display-language');
    els.displayScale = document.getElementById('display-scale');
    els.collectionFolderStatus = document.getElementById('collection-folder-status');
    els.btnSelectCollectionFolder = document.getElementById('btn-select-collection-folder');
    els.floatingButtonEnabled = document.getElementById('floating-button-enabled');
    els.disableRealtimePageSwitch = document.getElementById('disable-realtime-page-switch');
    els.includeTranscriptInSummaryBookmark = document.getElementById('include-transcript-in-summary-bookmark');
    els.maxOutputTokens = document.getElementById('max-output-tokens');
    els.autoScrollStreamingSummary = document.getElementById('auto-scroll-streaming-summary');
    els.autoSaveSummaryAsTxt = document.getElementById('auto-save-summary-as-txt');
    els.saveTextsAsMarkdown = document.getElementById('save-texts-as-markdown');
    els.sidebarBackgroundMode = document.getElementById('sidebar-background-mode');
    els.sidebarBackgroundDepth = document.getElementById('sidebar-background-depth');
    if (els.displayLanguage) els.displayLanguage.value = currentLanguage;
    if (els.displayScale) {
      els.displayScale.value = String(DISPLAY_SCALE_OPTIONS.indexOf(displayScale));
      els.displayScale.addEventListener('input', () => {
        applyDisplayScale(DISPLAY_SCALE_OPTIONS[Number(els.displayScale.value)]);
        queueSettingsSave();
      });
    }
    if (els.floatingButtonEnabled) els.floatingButtonEnabled.checked = floatingButtonEnabled;
    if (els.disableRealtimePageSwitch) els.disableRealtimePageSwitch.checked = disableRealtimePageSwitch;
    if (els.includeTranscriptInSummaryBookmark) els.includeTranscriptInSummaryBookmark.checked = includeTranscriptInSummaryBookmark;
    if (els.maxOutputTokens) els.maxOutputTokens.value = String(maxOutputTokens);
    if (els.autoScrollStreamingSummary) els.autoScrollStreamingSummary.checked = autoScrollStreamingSummary;
    if (els.autoSaveSummaryAsTxt) els.autoSaveSummaryAsTxt.checked = autoSaveSummaryAsTxt;
    if (els.saveTextsAsMarkdown) els.saveTextsAsMarkdown.checked = saveTextsAsMarkdown;
    applySidebarAppearance(sidebarBackgroundMode, sidebarBackgroundDepth);
    if (els.btnSelectCollectionFolder) {
      els.btnSelectCollectionFolder.addEventListener('click', () => {
        selectCollectionDirectory().then((handle) => {
          if (handle) showToast(tf('collectionFolderSelected', { name: handle.name }), 'success');
        }).catch((err) => showToast(collectionErrorMessage(err), 'error'));
      });
    }
    updateCollectionFolderDisplays();
  }

  function buildCreditEstimateBox() {
    if (document.getElementById('estimated-credit-box')) {
      els.estimatedCreditValue = document.getElementById('estimated-credit-value');
      return;
    }
    const box = document.createElement('button');
    box.type = 'button';
    box.id = 'estimated-credit-box';
    box.className = 'credit-estimate-box';
    box.innerHTML = t('balance') + ' <span id="estimated-credit-value">-</span>';
    box.addEventListener('click', () => {
      openDashboardNextToCurrent().catch((err) => showToast('打开我的失败: ' + err.message, 'error'));
    });
    // 学分余额从工具栏末尾挪到风格行：播放速度右边，空间由风格提示词下拉栏让出
    const slot = document.getElementById('credit-estimate-slot');
    if (slot) {
      slot.append(box);
    } else {
      const toolbar = els.btnToolbox?.closest('.toolbar-row');
      if (toolbar) {
        toolbar.append(box);
      } else {
        els.controls?.prepend(box);
      }
    }
    els.estimatedCreditValue = document.getElementById('estimated-credit-value');
    updateCreditBalanceDisplay();
  }

  let collectionHandleDbPromise = null;

  function openCollectionHandleDb() {
    if (!collectionHandleDbPromise) {
      collectionHandleDbPromise = new Promise((resolve, reject) => {
        if (!window.indexedDB) {
          reject(new Error('IndexedDB unavailable'));
          return;
        }
        const request = window.indexedDB.open(COLLECTION_HANDLE_DB_NAME, 2);
        request.onupgradeneeded = () => {
          const db = request.result;
          if (!db.objectStoreNames.contains(COLLECTION_HANDLE_STORE_NAME)) {
            db.createObjectStore(COLLECTION_HANDLE_STORE_NAME);
          }
          if (!db.objectStoreNames.contains(SUMMARY_HISTORY_STORE_NAME)) {
            db.createObjectStore(SUMMARY_HISTORY_STORE_NAME, { keyPath: 'id' });
          }
        };
        request.onsuccess = () => resolve(request.result);
        request.onerror = () => reject(request.error || new Error('Could not open collection storage'));
      });
    }
    return collectionHandleDbPromise;
  }

  async function getStoredCollectionDirectoryHandle() {
    const db = await openCollectionHandleDb();
    return new Promise((resolve, reject) => {
      const request = db.transaction(COLLECTION_HANDLE_STORE_NAME, 'readonly')
        .objectStore(COLLECTION_HANDLE_STORE_NAME)
        .get(COLLECTION_HANDLE_KEY);
      request.onsuccess = () => resolve(request.result || null);
      request.onerror = () => reject(request.error || new Error('Could not read collection storage'));
    });
  }

  async function storeCollectionDirectoryHandle(handle) {
    const db = await openCollectionHandleDb();
    return new Promise((resolve, reject) => {
      const transaction = db.transaction(COLLECTION_HANDLE_STORE_NAME, 'readwrite');
      transaction.objectStore(COLLECTION_HANDLE_STORE_NAME).put(handle, COLLECTION_HANDLE_KEY);
      transaction.oncomplete = () => resolve();
      transaction.onerror = () => reject(transaction.error || new Error('Could not save collection storage'));
      transaction.onabort = () => reject(transaction.error || new Error('Could not save collection storage'));
    });
  }

  async function queryCollectionDirectoryPermission(handle = collectionDirectoryHandle) {
    if (!handle) return 'unset';
    if (typeof handle.queryPermission !== 'function') return 'granted';
    try {
      return await handle.queryPermission({ mode: 'readwrite' });
    } catch (err) {
      return 'prompt';
    }
  }

  async function refreshCollectionDirectoryPermission() {
    if (!collectionDirectoryHandle) {
      collectionDirectoryPermission = typeof window.showDirectoryPicker === 'function' ? 'unset' : 'unsupported';
    } else {
      collectionDirectoryPermission = await queryCollectionDirectoryPermission(collectionDirectoryHandle);
    }
    updateCollectionFolderDisplays();
    return collectionDirectoryPermission;
  }

  async function loadCollectionDirectoryHandle() {
    if (typeof window.showDirectoryPicker !== 'function') {
      collectionDirectoryPermission = 'unsupported';
      updateCollectionFolderDisplays();
      return;
    }
    try {
      collectionDirectoryHandle = await getStoredCollectionDirectoryHandle();
    } catch (err) {
      console.warn('[BiliSummary] Could not restore the collection folder setting:', err);
      collectionDirectoryHandle = null;
    }
    await refreshCollectionDirectoryPermission();
  }

  function updateCollectionFolderDisplays() {
    const unsupported = typeof window.showDirectoryPicker !== 'function';
    const name = collectionDirectoryHandle?.name || '';
    let statusText = t('collectionFolderUnset');
    let needsPermission = false;
    if (unsupported) {
      statusText = t('collectionUnsupported');
    } else if (name && collectionDirectoryPermission !== 'denied') {
      statusText = tf('collectionFolderSelected', { name });
    } else if (name) {
      statusText = tf('collectionFolderNeedsPermission', { name });
      needsPermission = true;
    }
    if (els.collectionFolderStatus) {
      els.collectionFolderStatus.textContent = statusText;
      els.collectionFolderStatus.title = statusText;
      els.collectionFolderStatus.classList.toggle('needs-permission', needsPermission);
    }
    if (els.btnSelectCollectionFolder) {
      els.btnSelectCollectionFolder.textContent = name ? t('changeFolder') : t('selectFolder');
      els.btnSelectCollectionFolder.disabled = unsupported;
    }
    if (els.collectionBrowserFolder) els.collectionBrowserFolder.textContent = statusText;
    if (els.toolboxFolder) els.toolboxFolder.textContent = '保存到：' + statusText;
    if (els.btnChooseCollectionFolder) {
      els.btnChooseCollectionFolder.textContent = name ? t('changeFolder') : t('selectFolder');
      els.btnChooseCollectionFolder.disabled = unsupported;
    }
  }

  function collectionErrorMessage(err, fallbackKey = 'collectionSaveFailed') {
    if (!err) return t(fallbackKey);
    if (err.name === 'NotAllowedError' || err.name === 'SecurityError') return t('collectionPermissionDenied');
    if (err.name === 'NotFoundError') return t('collectionUnavailable');
    if (err.name === 'NoModificationAllowedError') return t('collectionReadOnly');
    if (err.name === 'NotSupportedError') return t('collectionUnsupported');
    return t(fallbackKey);
  }

  async function selectCollectionDirectory() {
    if (typeof window.showDirectoryPicker !== 'function') {
      const err = new Error('Directory picker unavailable');
      err.name = 'NotSupportedError';
      throw err;
    }
    let handle;
    try {
      handle = await window.showDirectoryPicker({
        id: 'bili-summary-collection-folder',
        mode: 'readwrite',
      });
    } catch (err) {
      if (err?.name === 'AbortError') return null;
      throw err;
    }
    await storeCollectionDirectoryHandle(handle);
    collectionDirectoryHandle = handle;
    collectionDirectoryPermission = await queryCollectionDirectoryPermission(handle);
    updateCollectionFolderDisplays();
    return handle;
  }

  async function ensureCollectionDirectoryPermission(requestIfNeeded = false) {
    if (!collectionDirectoryHandle) return false;
    let permission = await queryCollectionDirectoryPermission(collectionDirectoryHandle);
    if (permission !== 'granted' && requestIfNeeded && typeof collectionDirectoryHandle.requestPermission === 'function') {
      try {
        permission = await collectionDirectoryHandle.requestPermission({ mode: 'readwrite' });
      } catch (err) {
        permission = 'denied';
      }
    }
    collectionDirectoryPermission = permission;
    updateCollectionFolderDisplays();
    return permission === 'granted';
  }

  function setCollectionListState(message) {
    if (!els.collectionList) return;
    els.collectionList.textContent = '';
    const state = document.createElement('div');
    state.className = 'collection-list-state';
    state.textContent = message;
    els.collectionList.appendChild(state);
  }

  function formatCollectionFileDate(timestamp) {
    if (!timestamp) return '';
    const locale = currentLanguage === 'en' ? 'en-US' : (currentLanguage === 'zh-TW' ? 'zh-TW' : 'zh-CN');
    return new Date(timestamp).toLocaleString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
  }

  async function previewCollectionFile(entry) {
    try {
      const file = await entry.handle.getFile();
      els.collectionPreviewTitle.textContent = entry.name;
      els.collectionPreviewContent.textContent = await file.text();
      els.collectionList.classList.add('hidden');
      els.collectionPreview.classList.remove('hidden');
    } catch (err) {
      showToast(collectionErrorMessage(err, 'collectionLoadFailed'), 'error');
    }
  }

  async function renderCollectionEntries({ requestPermission = false } = {}) {
    els.collectionPreview.classList.add('hidden');
    els.collectionList.classList.remove('hidden');
    if (!collectionDirectoryHandle) {
      setCollectionListState(typeof window.showDirectoryPicker === 'function' ? t('collectionChooseFirst') : t('collectionUnsupported'));
      return;
    }
    setCollectionListState(t('collectionLoading'));
    if (!(await ensureCollectionDirectoryPermission(requestPermission))) {
      setCollectionListState(t('collectionPermissionDenied'));
      return;
    }
    try {
      const entries = [];
      for await (const handle of collectionDirectoryHandle.values()) {
        if (handle.kind !== 'file' || !/\.(txt|md)$/i.test(handle.name)) continue;
        const file = await handle.getFile();
        entries.push({ name: handle.name, handle, lastModified: file.lastModified });
      }
      entries.sort((a, b) => b.lastModified - a.lastModified || a.name.localeCompare(b.name));
      if (!entries.length) {
        setCollectionListState(t('collectionEmpty'));
        return;
      }
      els.collectionList.textContent = '';
      entries.forEach((entry) => {
        const button = document.createElement('button');
        button.type = 'button';
        button.className = 'collection-file-item';
        const name = document.createElement('span');
        name.className = 'collection-file-name';
        name.textContent = entry.name;
        name.title = entry.name;
        const date = document.createElement('span');
        date.className = 'collection-file-date';
        date.textContent = formatCollectionFileDate(entry.lastModified);
        button.append(name, date);
        button.addEventListener('click', () => previewCollectionFile(entry));
        els.collectionList.appendChild(button);
      });
    } catch (err) {
      setCollectionListState(collectionErrorMessage(err, 'collectionLoadFailed'));
    }
  }

  async function openCollectionBrowser() {
    els.settingsPanel.classList.add('hidden');
    els.toolboxPanel.classList.add('hidden');
    els.historyPanel.classList.add('hidden');
    els.mobileSummaryPanel.classList.add('hidden');
    els.collectionPanel.classList.remove('hidden');
    await refreshCollectionDirectoryPermission();
    await renderCollectionEntries({ requestPermission: true });
  }

  async function loadSettings() {
    // 清理已撤回的“复制路径”功能曾保存的本地完整路径。
    await chrome.storage.local.remove(['collectionFolderPath', 'pendingKimiSummary']);
    const r = await chrome.storage.local.get([
      'user', 'displayLanguage', 'displayScale', RESULT_FONT_LEVEL_KEY, 'followUpDockExpanded', 'disableRealtimePageSwitch', FLOATING_BUTTON_ENABLED_KEY,
      HISTORY_HINT_COLLAPSED_KEY,
    ]);
    currentUserTier = r.user?.member_tier || currentUserTier;
    currentLanguage = normalizeLanguage(r.user?.language || r.displayLanguage);
    applyDisplayScale(r.displayScale);
    applyResultFontLevel(r[RESULT_FONT_LEVEL_KEY]);
    followUpExpanded = !!r.followUpDockExpanded;
    floatingButtonEnabled = r[FLOATING_BUTTON_ENABLED_KEY] !== false;
    disableRealtimePageSwitch = !!r.disableRealtimePageSwitch;
    historyHintCollapsed = r[HISTORY_HINT_COLLAPSED_KEY] === true;
    const speedStorage = await chrome.storage.local.get({ [PLAYBACK_SPEED_KEY]: 1 });
    updatePlaybackSpeedButton(normalizePlaybackSpeed(speedStorage[PLAYBACK_SPEED_KEY]));
    if (els.floatingButtonEnabled) els.floatingButtonEnabled.checked = floatingButtonEnabled;
    document.documentElement.lang = currentLanguage;
    applyHistoryHintState();
    applyLanguageToControls();
    await loadAccountSettings(r.user);
  }

  function accountStorageKey(name) {
    return accountSettingsUserId ? name + ':user:' + accountSettingsUserId : '';
  }

  function clearCustomStyleOptions() {
    [...els.styleSelect.options].filter((option) => option.value.startsWith('custom_')).forEach((option) => option.remove());
    els.styleSelect.value = 'standard';
  }

  async function loadAccountSettings(user) {
    const previousUserId = accountSettingsUserId;
    accountSettingsUserId = user?.id ? String(user.id) : '';
    clearCustomStyleOptions();
    if (!accountSettingsUserId) {
      includeTranscriptInSummaryBookmark = false;
      autoSaveSummaryAsTxt = false;
      saveTextsAsMarkdown = false;
      autoScrollStreamingSummary = false;
      sidebarBackgroundMode = 'default';
      sidebarBackgroundDepth = 50;
      maxOutputTokens = MAX_OUTPUT_TOKENS_DEFAULT;
      if (els.includeTranscriptInSummaryBookmark) els.includeTranscriptInSummaryBookmark.checked = false;
      if (els.autoScrollStreamingSummary) els.autoScrollStreamingSummary.checked = false;
      if (els.autoSaveSummaryAsTxt) els.autoSaveSummaryAsTxt.checked = false;
      if (els.saveTextsAsMarkdown) els.saveTextsAsMarkdown.checked = false;
      applySidebarAppearance('default', 50);
      if (els.maxOutputTokens) els.maxOutputTokens.value = String(maxOutputTokens);
      applyColorSettings({});
      updateStyleControls();
      if (previousUserId) await loadSummaryHistory();
      return;
    }
    const keys = [
      accountStorageKey('customStyles'),
      accountStorageKey('selectedStyle'),
      accountStorageKey('styleAdditions'),
      accountStorageKey('titleColor'),
      accountStorageKey('accentColor'),
      accountStorageKey('startButtonColor'),
      accountStorageKey('includeTranscriptInSummaryBookmark'),
      accountStorageKey('autoSaveSummaryAsTxt'),
      accountStorageKey('saveTextsAsMarkdown'),
      accountStorageKey('autoScrollStreamingSummary'),
      accountStorageKey('sidebarBackgroundMode'),
      accountStorageKey('sidebarBackgroundDepth'),
      accountStorageKey('sidebarDarkMode'),
      accountStorageKey('sidebarBackgroundColor'),
      accountStorageKey('maxOutputTokens'),
      accountStorageKey('summaryMaxTokens'),
      accountStorageKey('followUpMaxTokens'),
    ];
    const saved = await chrome.storage.local.get(keys);
    const styles = saved[accountStorageKey('customStyles')];
    if (Array.isArray(styles)) styles.forEach((style) => addStyleOption(style.value, style.label, false));
    const selectedStyle = saved[accountStorageKey('selectedStyle')];
    els.styleSelect.value = [...els.styleSelect.options].some((option) => option.value === selectedStyle) ? selectedStyle : 'standard';
    includeTranscriptInSummaryBookmark = !!saved[accountStorageKey('includeTranscriptInSummaryBookmark')];
    autoSaveSummaryAsTxt = !!saved[accountStorageKey('autoSaveSummaryAsTxt')];
    saveTextsAsMarkdown = !!saved[accountStorageKey('saveTextsAsMarkdown')];
    autoScrollStreamingSummary = !!saved[accountStorageKey('autoScrollStreamingSummary')];
    const backgroundModeKey = accountStorageKey('sidebarBackgroundMode');
    const backgroundDepthKey = accountStorageKey('sidebarBackgroundDepth');
    const legacyDarkModeKey = accountStorageKey('sidebarDarkMode');
    const legacyBackgroundColorKey = accountStorageKey('sidebarBackgroundColor');
    sidebarBackgroundMode = normalizeSidebarBackgroundMode(
      saved[backgroundModeKey] ?? (saved[legacyDarkModeKey] ? 'night' : 'default')
    );
    sidebarBackgroundDepth = normalizeSidebarBackgroundDepth(saved[backgroundDepthKey]);
    if (!(backgroundModeKey in saved) && (legacyDarkModeKey in saved || legacyBackgroundColorKey in saved)) {
      await chrome.storage.local.set({
        [backgroundModeKey]: sidebarBackgroundMode,
        [backgroundDepthKey]: sidebarBackgroundDepth,
      });
      await chrome.storage.local.remove([legacyDarkModeKey, legacyBackgroundColorKey]);
    }
    const legacyMaxTokens = [
      saved[accountStorageKey('summaryMaxTokens')],
      saved[accountStorageKey('followUpMaxTokens')],
    ]
      .map((value) => Number(value))
      .filter((value) => Number.isFinite(value) && value > 0);
    maxOutputTokens = normalizeMaxOutputTokens(saved[accountStorageKey('maxOutputTokens')] ?? (legacyMaxTokens.length ? Math.min(...legacyMaxTokens) : MAX_OUTPUT_TOKENS_DEFAULT));
    if (els.includeTranscriptInSummaryBookmark) els.includeTranscriptInSummaryBookmark.checked = includeTranscriptInSummaryBookmark;
    if (els.autoScrollStreamingSummary) els.autoScrollStreamingSummary.checked = autoScrollStreamingSummary;
    if (els.autoSaveSummaryAsTxt) els.autoSaveSummaryAsTxt.checked = autoSaveSummaryAsTxt;
    if (els.saveTextsAsMarkdown) els.saveTextsAsMarkdown.checked = saveTextsAsMarkdown;
    applySidebarAppearance(sidebarBackgroundMode, sidebarBackgroundDepth);
    if (els.maxOutputTokens) els.maxOutputTokens.value = String(maxOutputTokens);
    applyColorSettings({
      titleColor: saved[accountStorageKey('titleColor')],
      accentColor: saved[accountStorageKey('accentColor')],
      startButtonColor: saved[accountStorageKey('startButtonColor')],
    });
    updateStyleControls();
    if (previousUserId !== accountSettingsUserId) {
      await loadSummaryHistory();
      await syncPendingSummaryResults();
    }
  }

  function applyLanguageToControls() {
    const options = els.styleSelect?.options;
    if (options) {
      options[0].textContent = t('concise');
      options[1].textContent = t('standard');
      options[2].textContent = t('detailed');
    }
    const settingsTitle = els.settingsPanel?.querySelector('.settings-header h3');
    if (settingsTitle) settingsTitle.textContent = t('settings');
    const floatingButtonLabel = els.floatingButtonEnabled?.closest('label')?.querySelector('span');
    if (floatingButtonLabel) floatingButtonLabel.textContent = t('enableFloatingButton');
    const subtitleTab = document.getElementById('tab-subtitle');
    const summaryTab = document.getElementById('tab-summary');
    if (subtitleTab?.querySelector('.tab-label')) subtitleTab.querySelector('.tab-label').textContent = t('subtitle');
    if (summaryTab?.querySelector('.tab-label')) summaryTab.querySelector('.tab-label').textContent = t('summary');
    const followUpLockTip = document.getElementById('followup-lock-tip');
    if (followUpLockTip) followUpLockTip.textContent = t('followUpLocked');
    const settingsButtonLabel = els.btnSettings?.querySelector('span');
    const accountButtonLabel = els.btnServer?.querySelector('span');
    const toolboxButtonLabel = els.btnToolbox?.querySelector('span');
    if (settingsButtonLabel) settingsButtonLabel.textContent = t('settings');
    const historyButtonLabel = els.btnHistory?.querySelector('span');
    if (historyButtonLabel) historyButtonLabel.textContent = t('history');
    const emptyAccountButtonLabel = els.btnEmptyServer?.querySelector('span');
    if (emptyAccountButtonLabel) emptyAccountButtonLabel.textContent = t('myAccount');
    const historyTitle = els.historyPanel?.querySelector('.settings-header h3');
    if (historyTitle) historyTitle.textContent = t('summaryHistory');
    if (els.historyHintText) els.historyHintText.textContent = t('historyHint');
    applyHistoryHintState();
    if (els.historyList && summaryHistory.length) renderHistoryList();
    if (accountButtonLabel) accountButtonLabel.textContent = t('myAccount');
    if (toolboxButtonLabel) toolboxButtonLabel.textContent = t('toolbox');
    if (els.btnBookmark) els.btnBookmark.title = t('bookmark');
    if (els.btnCopy) els.btnCopy.title = t('copy');
    if (els.subtitleSearchInput) els.subtitleSearchInput.placeholder = t('subtitleSearchPlaceholder');
    if (els.subtitleSearchBtn) {
      els.subtitleSearchBtn.title = t('subtitleSearch');
      els.subtitleSearchBtn.setAttribute('aria-label', t('subtitleSearch'));
    }
    if (els.subtitleLocateBtn) {
      els.subtitleLocateBtn.title = t('subtitleLocateTip');
      els.subtitleLocateBtn.setAttribute('aria-label', t('subtitleLocate'));
    }
    updateResultFontControls();
    if (els.btnSubtitleSearchClose) {
      els.btnSubtitleSearchClose.title = t('subtitleSearchClose');
      els.btnSubtitleSearchClose.setAttribute('aria-label', t('subtitleSearchClose'));
    }
    const balanceBox = document.getElementById('estimated-credit-box');
    if (balanceBox?.firstChild) {
      balanceBox.firstChild.nodeValue = t('balance') + ' ';
    }
    if (els.customPromptInput) els.customPromptInput.placeholder = t('customStyle');
    if (els.assistPromptInput) els.assistPromptInput.placeholder = t('assistStylePlaceholder');
    if (els.btnAssistStyle) els.btnAssistStyle.textContent = t('assistStyleGenerate');
    if (els.btnAddStyle) els.btnAddStyle.title = t('addCustomStyle');
    const styleManagerLabels = els.styleManagerPanel?.querySelectorAll('.style-manager-section-label');
    if (styleManagerLabels && styleManagerLabels.length >= 2) {
      styleManagerLabels[0].textContent = t('styleAssistLabel');
      styleManagerLabels[1].textContent = t('styleDirectAddLabel');
    }
    if (els.btnManageStyles) els.btnManageStyles.title = currentLanguage === 'en' ? 'Manage custom styles' : (currentLanguage === 'zh-TW' ? '管理自訂風格' : '管理自定义风格');
    if (els.btnPlaybackSpeed) {
      els.btnPlaybackSpeed.title = t('playbackSpeed');
      els.btnPlaybackSpeed.setAttribute('aria-label', formatPlaybackSpeed(playbackSpeed));
    }
    if (els.playbackSpeedPopover) els.playbackSpeedPopover.setAttribute('aria-label', t('playbackSpeed'));
    if (els.btnMobileSummary) els.btnMobileSummary.setAttribute('aria-label', currentLanguage === 'en' ? 'Mobile summary (Beta)' : (currentLanguage === 'zh-TW' ? '手機端總結（Beta）' : '手机端总结（Beta）'));
    renderMobileSummaryAccess();
    if (els.mobileSummaryPanel) {
      const mobileHeading = els.mobileSummaryPanel.querySelector('h3');
      if (mobileHeading) mobileHeading.textContent = currentLanguage === 'en' ? 'Mobile summary (Beta)' : (currentLanguage === 'zh-TW' ? '手機端總結（Beta）' : '手机端总结（Beta）');
      if (els.shareCommentTooltip) {
        els.shareCommentTooltip.textContent = currentLanguage === 'en'
          ? 'One-click share of your summary in the comment section'
          : (currentLanguage === 'zh-TW' ? '一鍵在評論區分享總結的好人功能' : '一键在评论区分享总结的好人功能');
      }
    }
    const collectionLabel = els.btnCollection?.querySelector('span');
    if (collectionLabel) collectionLabel.textContent = t('collection');
    const toolboxActionTooltip = document.getElementById('toolbox-action-tooltip');
    const collectionActionTooltip = document.getElementById('collection-action-tooltip');
    if (toolboxActionTooltip) toolboxActionTooltip.textContent = t('toolboxTip');
    if (collectionActionTooltip) collectionActionTooltip.textContent = t('collectionTip');
    const flashcardLabel = els.btnStudyFlashcards?.querySelector('span');
    const quizLabel = els.btnStudyQuiz?.querySelector('span');
    const mindmapLabel = els.btnStudyMindmap?.querySelector('span');
    if (flashcardLabel) flashcardLabel.textContent = t('studyFlashcards');
    if (quizLabel) quizLabel.textContent = t('studyQuiz');
    if (mindmapLabel) mindmapLabel.textContent = t('studyMindmap');
    updateStudyActionState();
    const collectionTitle = els.collectionPanel?.querySelector('.settings-header h3');
    if (collectionTitle) collectionTitle.textContent = t('collection');
    if (els.btnRefreshCollection) els.btnRefreshCollection.textContent = t('refreshCollection');
    if (els.btnBackCollection) els.btnBackCollection.textContent = t('backToCollection');
    updateCollectionFolderDisplays();
    if (els.btnStart && !isProcessing && !isAudioTranscribing) updateStartButtonIdle();
    buildSettingsLayout();
    if (videoData) showVideoInfo(videoData);
    if (currentResultMode === 'subtitle' && lastSubtitleText) renderSubtitleText(lastSubtitleText);
    renderFollowUpDock();
    updateStartCreditTooltip();
  }

  function updateColorCustomizationAccess() {
    const section = document.getElementById('summary-text-color-settings');
    if (!section) return;
    const canCustomize = canCustomizeSummaryTextColors();
    section.classList.toggle('is-locked', !canCustomize);
    section.querySelectorAll('.color-palette').forEach((palette) => {
      palette.setAttribute('aria-disabled', canCustomize ? 'false' : 'true');
      palette.title = canCustomize ? '' : t('paidMembers');
    });
    if (els.autoScrollStreamingSummary) {
      els.autoScrollStreamingSummary.disabled = !canCustomize;
      const label = els.autoScrollStreamingSummary.closest('label');
      label?.classList.toggle('is-disabled', !canCustomize);
      label?.setAttribute('title', canCustomize ? '' : t('paidMembers'));
    }
    [els.sidebarBackgroundDepth, els.includeTranscriptInSummaryBookmark, els.autoSaveSummaryAsTxt, els.saveTextsAsMarkdown].forEach((control) => {
      if (!control) return;
      control.disabled = !canCustomize;
      control.closest('.control-group')?.classList.toggle('is-disabled', !canCustomize);
      control.setAttribute('title', canCustomize ? '' : t('paidMembers'));
    });
    els.sidebarBackgroundMode?.querySelectorAll('button').forEach((button) => {
      button.disabled = !canCustomize;
      button.title = canCustomize ? '' : t('paidMembers');
    });
    applySidebarAppearance(sidebarBackgroundMode, sidebarBackgroundDepth);
  }

  function applyColorSettings(settings = {}) {
    const defaults = getDefaultColors();
    const canCustomize = canCustomizeSummaryTextColors();
    const titleColor = canCustomize && isPaletteColor(settings.titleColor) ? settings.titleColor : defaults.titleColor;
    const accentColor = canCustomize && isPaletteColor(settings.accentColor) ? settings.accentColor : defaults.accentColor;
    const startButtonColor = isPaletteColor(settings.startButtonColor) ? settings.startButtonColor : defaults.startButtonColor;
    applyTitleColor(titleColor);
    setActiveSwatch(els.titleColorPalette, titleColor);
    applyAccentColor(accentColor);
    setActiveSwatch(els.colorPalette, accentColor);
    applyStartButtonColor(startButtonColor);
    setActiveSwatch(els.startColorPalette, startButtonColor);
    updateColorCustomizationAccess();
  }

  async function refreshDefaultColorsForTier() {
    const { user } = await chrome.storage.local.get(['user']);
    await loadAccountSettings(user);
  }

  function isPaidMembershipTier(tier) {
    return tier === 'admin' || tier === 'duxue' || tier === 'mengxue';
  }

  async function syncMembershipTierFromQuota(quota) {
    const nextTier = quota?.member_tier || 'qinxue';
    if (nextTier === currentUserTier) return false;
    const previousTier = currentUserTier;
    currentUserTier = nextTier;
    const stored = await chrome.storage.local.get({ user: null });
    const user = stored.user && typeof stored.user === 'object'
      ? {
        ...stored.user,
        member_tier: nextTier,
        subscription_plan: quota?.subscription_plan || stored.user.subscription_plan,
        subscription_expire_at: quota?.subscription_expire_at ?? stored.user.subscription_expire_at,
      }
      : null;
    if (user) {
      await chrome.storage.local.set({ user });
      await loadAccountSettings(user);
    } else {
      applyColorSettings({});
      updateStyleControls();
    }
    renderFollowUpDock();
    updateStudyActionState();
    if (!isProcessing && !isAudioTranscribing) updateStartButtonIdle();
    else updateStartCreditTooltip();
    if (isPaidMembershipTier(previousTier) && nextTier === 'qinxue') {
      showToast('会员已到期：标题和粗体颜色已恢复默认，追问功能已锁定', 'info');
    }
    return true;
  }

  async function refreshMembershipAccess() {
    try {
      const result = await chrome.runtime.sendMessage({ action: 'serverQuota' });
      if (!result?.success || !result.quota) return null;
      updateCreditBalanceDisplay(result.quota.remaining);
      currentAudioSummaryQuota = result.quota.audio_summary_daily || result.quota.fast_audio_daily || null;
      await syncMembershipTierFromQuota(result.quota);
      updateAudioSummaryActions();
      return result.quota;
    } catch {
      return null;
    }
  }

  function setupEventListeners() {
    els.btnToolbox.addEventListener('click', openToolbox);
    els.btnStudyFlashcards.addEventListener('click', () => openStudyPage('flashcards'));
    els.btnStudyQuiz.addEventListener('click', () => openStudyPage('quiz'));
    els.btnStudyMindmap.addEventListener('click', () => openStudyPage('mindmap'));
    els.btnCollection.addEventListener('click', async (event) => {
      event.preventDefault();
      try {
        await openCollectionBrowser();
      } catch (err) {
        showToast(collectionErrorMessage(err, 'collectionLoadFailed'), 'error');
      }
    });
    els.statusClose.addEventListener('click', clearStatus);
    els.advancedNoticeClose.addEventListener('click', dismissAdvancedNotice);
    els.advancedNoticeAction.addEventListener('click', openAdvancedNoticeAction);
    els.btnBookmark.addEventListener('click', bookmarkResult);
    els.btnSettings.addEventListener('click', () => {
      els.collectionPanel.classList.add('hidden');
      els.toolboxPanel.classList.add('hidden');
      els.historyPanel.classList.add('hidden');
      els.styleManagerPanel.classList.add('hidden');
      els.mobileSummaryPanel.classList.add('hidden');
      els.settingsPanel.classList.remove('hidden');
      void Promise.allSettled([refreshMembershipAccess(), refreshCollectionDirectoryPermission()]);
    });
    els.btnCloseSettings.addEventListener('click', () => els.settingsPanel.classList.add('hidden'));
    els.btnCloseCollection.addEventListener('click', () => els.collectionPanel.classList.add('hidden'));
    els.btnCloseToolbox.addEventListener('click', () => els.toolboxPanel.classList.add('hidden'));
    els.btnDownloadCover.addEventListener('click', () => runToolboxAction(els.btnDownloadCover, downloadVideoCover));
    els.btnDownloadComments.addEventListener('click', () => runToolboxAction(els.btnDownloadComments, downloadVideoComments));
    els.btnDownloadAudio.addEventListener('click', () => enqueueMediaDownload('audio', false).catch((err) => showToast(err.message || '下载任务提交失败', 'error')));
    els.btnDownloadAllAudio.addEventListener('click', () => enqueueMediaDownload('audio', true).catch((err) => showToast(err.message || '下载任务提交失败', 'error')));
    els.btnDownloadVideo.addEventListener('click', () => enqueueMediaDownload('video', false).catch((err) => showToast(err.message || '下载任务提交失败', 'error')));
    els.btnDownloadAllVideo.addEventListener('click', () => enqueueMediaDownload('video', true).catch((err) => showToast(err.message || '下载任务提交失败', 'error')));
    els.btnCloseDownloadComplete.addEventListener('click', () => els.toolboxDownloadComplete.classList.add('hidden'));
    els.btnChooseCollectionFolder.addEventListener('click', async () => {
      try {
        const handle = await selectCollectionDirectory();
        if (handle) {
          showToast(tf('collectionFolderSelected', { name: handle.name }), 'success');
          await renderCollectionEntries();
        }
      } catch (err) {
        showToast(collectionErrorMessage(err), 'error');
      }
    });
    els.btnRefreshCollection.addEventListener('click', () => {
      renderCollectionEntries({ requestPermission: true }).catch((err) => {
        showToast(collectionErrorMessage(err, 'collectionLoadFailed'), 'error');
      });
    });
    els.btnBackCollection.addEventListener('click', () => {
      els.collectionPreview.classList.add('hidden');
      els.collectionList.classList.remove('hidden');
    });
    els.btnManageStyles.addEventListener('click', openStyleManager);
    els.btnPlaybackSpeed?.addEventListener('click', (event) => {
      event.stopPropagation();
      togglePlaybackSpeedPopover();
    });
    els.playbackSpeedPopover?.addEventListener('click', (event) => {
      const rateButton = event.target.closest('.playback-speed-grid button');
      if (!rateButton) return;
      setPlaybackSpeed(Number(rateButton.dataset.rate));
      closePlaybackSpeedPopover();
    });
    document.addEventListener('click', (event) => {
      if (!els.playbackSpeedPopover || els.playbackSpeedPopover.classList.contains('hidden')) return;
      if (event.target.closest('.playback-speed-wrap')) return;
      closePlaybackSpeedPopover();
    });
    document.addEventListener('keydown', (event) => {
      if (event.key === 'Escape') closePlaybackSpeedPopover();
    });
    els.btnCloseStyleManager.addEventListener('click', () => els.styleManagerPanel.classList.add('hidden'));
    els.btnAssistStyle.addEventListener('click', assistStyleGeneration);
    els.btnAddStyle.addEventListener('click', addCustomStyle);
    els.customPromptInput.addEventListener('animationend', () => els.customPromptInput.classList.remove('style-assist-highlight'));
    els.styleSelect.addEventListener('change', () => {
      updateStyleControls();
      const selectedValue = els.styleSelect.value || '';
      if (BUILTIN_STYLE_TIPS[selectedValue]) showToast(BUILTIN_STYLE_TIPS[selectedValue], 'info', 3000);
      saveSelectedStyle();
      if (lastSubtitleText) updateEstimatedSummaryCredits(lastSubtitleText);
    });
    els.customPromptInput.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
        e.preventDefault();
        addCustomStyle();
      }
    });
    els.settingsPanel.addEventListener('change', (e) => {
      if (['display-language', 'floating-button-enabled', 'disable-realtime-page-switch', 'include-transcript-in-summary-bookmark', 'auto-scroll-streaming-summary', 'auto-save-summary-as-txt', 'save-texts-as-markdown', 'sidebar-background-depth', 'max-output-tokens'].includes(e.target.id)) {
        if (e.target.id === 'sidebar-background-depth' && canCustomizeSummaryTextColors()) {
          applySidebarAppearance(sidebarBackgroundMode, e.target.value);
        }
        if (e.target.id === 'max-output-tokens') {
          maxOutputTokens = normalizeMaxOutputTokens(e.target.value);
          e.target.value = String(maxOutputTokens);
          if (lastSubtitleText) updateEstimatedSummaryCredits(lastSubtitleText);
        }
        queueSettingsSave();
      }
    });
    els.settingsPanel.addEventListener('input', (e) => {
      if (e.target.id === 'sidebar-background-depth' && canCustomizeSummaryTextColors()) {
        applySidebarAppearance(sidebarBackgroundMode, e.target.value);
        queueSettingsSave();
      }
    });
    // 设置面板会在切换语言后重建，使用事件代理保证新控件仍可即时保存。
    els.settingsPanel.addEventListener('click', (e) => {
      const backgroundModeButton = e.target.closest('#sidebar-background-mode button');
      if (backgroundModeButton) {
        if (!canCustomizeSummaryTextColors()) return showToast(t('paidMembers'), 'info');
        applySidebarAppearance(backgroundModeButton.dataset.mode, sidebarBackgroundDepth);
        queueSettingsSave();
        return;
      }
      const swatch = e.target.closest('.color-swatch');
      if (!swatch) return;
      const palette = swatch.closest('.color-palette');
      if (palette?.id === 'title-color-palette') {
        if (!canCustomizeSummaryTextColors()) return showToast('笃学会员、博学会员可设置标题颜色', 'info');
        setActiveSwatch(palette, swatch.dataset.color);
        applyTitleColor(swatch.dataset.color);
      } else if (palette?.id === 'color-palette') {
        if (!canCustomizeSummaryTextColors()) return showToast('笃学会员、博学会员可设置粗体颜色', 'info');
        setActiveSwatch(palette, swatch.dataset.color);
        applyAccentColor(swatch.dataset.color);
      } else if (palette?.id === 'start-color-palette') {
        setActiveSwatch(palette, swatch.dataset.color);
        applyStartButtonColor(swatch.dataset.color);
      } else {
        return;
      }
      queueSettingsSave();
    });
    els.btnStart.addEventListener('click', () => startSummary());
    els.btnStartSlow.addEventListener('click', () => slowAsrQueued ? cancelSlowSummaryQueue() : startAudioSummary('slow'));
    els.btnStartFast.addEventListener('click', () => {
      if (currentUserTier === 'qinxue') {
        showToast('当前会员不支持使用音频总结fast', 'info');
        return;
      }
      startAudioSummary('fast');
    });
    [['slow', els.btnStartSlow], ['fast', els.btnStartFast]].forEach(([mode, button]) => {
      button.addEventListener('contextmenu', (event) => {
        event.preventDefault();
        const totalCredits = estimatedAudioTotalCredits(mode);
        const timeNote = mode === 'slow'
          ? `总用时约为视频时长的六分之一，即${Math.max(1, Math.round(Number(videoData?.duration || 0) / 360))}分钟`
          : '上传音频后速度较快';
        showToast(`${totalCredits}学分：音频转写约${audioTranscriptionCredits(mode)}学分+总结约${estimatedAudioSummaryCredits()}学分。${timeNote}`, 'info', { duration: 3500 });
      });
    });
    els.btnStartKimi.addEventListener('click', startKimiSummary);
    els.btnCopy.addEventListener('click', copyResult);
    els.btnShareComment?.addEventListener('click', commentCurrentVideoWithSummary);
    els.followUpSubmit.addEventListener('click', submitFollowUp);
    els.followUpAttach?.addEventListener('click', () => {
      carryTranscript = !carryTranscript;
      renderFollowUpDock();
      showToast(carryTranscript ? t('followUpCarryEnabled') : t('followUpCarryDisabled'), '', { bottom: true, duration: 1500 });
      // 点击后仍悬停时会同时显示别针浮窗与底部提示，先压住别针浮窗，鼠标移开再恢复。
      const attachBtn = els.followUpAttach;
      attachBtn.classList.add('tip-suppressed');
      attachBtn.addEventListener('mouseleave', () => attachBtn.classList.remove('tip-suppressed'), { once: true });
      els.followUpInput.focus();
    });
    els.followUpInput.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
        e.preventDefault();
        submitFollowUp();
      }
    });
    // 账户面板
    els.btnServer.addEventListener('click', () => {
      openDashboardNextToCurrent().catch((err) => showToast('打开我的失败: ' + err.message, 'error'));
    });
    els.btnEmptyServer?.addEventListener('click', () => {
      openDashboardNextToCurrent().catch((err) => showToast('打开我的失败: ' + err.message, 'error'));
    });
    els.btnHistory?.addEventListener('click', openHistoryPanel);
    els.btnCloseHistory?.addEventListener('click', () => els.historyPanel.classList.add('hidden'));
    els.btnToggleHistoryHint?.addEventListener('click', toggleHistoryHint);
    attachStudyTooltipPositioning();
    attachShareTooltipPositioning();
    els.btnMobileSummary?.addEventListener('click', openMobileSummary);
    els.btnMobileSummary?.addEventListener('mouseenter', () => { if (!mobileBetaAccess.loaded) refreshMobileSummaryAccess(); });
    els.btnMobileSummary?.addEventListener('focus', () => { if (!mobileBetaAccess.loaded) refreshMobileSummaryAccess(); });
    els.btnCloseMobileSummary?.addEventListener('click', () => els.mobileSummaryPanel.classList.add('hidden'));
    els.btnFollowBot?.addEventListener('click', followBotAccount);
    els.btnDetectUid?.addEventListener('click', () => {
      if (mobileBoundAccount) unlinkBilibiliUid();
      else detectBilibiliUid({ force: true });
    });
    els.btnManualUid?.addEventListener('click', () => {
      els.mobileSummaryManualRow.classList.toggle('hidden');
      if (!els.mobileSummaryManualRow.classList.contains('hidden')) {
        els.mobileSummaryUidInput.focus();
      }
    });
    const saveManualUid = () => saveManualBilibiliUid();
    els.btnSaveManualUid?.addEventListener('click', saveManualUid);
    els.mobileSummaryUidInput?.addEventListener('keydown', (event) => {
      if (event.key === 'Enter') saveManualUid();
    });
    els.btnOsIos?.addEventListener('click', () => voteMobileOs('ios'));
    els.btnOsAndroid?.addEventListener('click', () => voteMobileOs('android'));
    els.btnOpenStudyAgent?.addEventListener('click', openStudyAgentWebsite);
    els.mobileSummaryHow?.addEventListener('click', (event) => {
      if (event.target.classList.contains('mobile-summary-lookup')) openMobileSummaryModal();
    });
    els.btnCloseMobileSummaryModal?.addEventListener('click', closeMobileSummaryModal);
    els.mobileSummaryModal?.addEventListener('click', (event) => {
      if (event.target === els.mobileSummaryModal) closeMobileSummaryModal();
    });
  }

  function applyHistoryHintState() {
    if (!els.historyHint) return;
    els.historyHint.classList.toggle('collapsed', historyHintCollapsed);
    if (els.btnToggleHistoryHint) {
      els.btnToggleHistoryHint.textContent = historyHintCollapsed ? t('historyHintExpand') : t('historyHintCollapse');
      els.btnToggleHistoryHint.setAttribute('aria-expanded', String(!historyHintCollapsed));
    }
  }

  function toggleHistoryHint() {
    if (!els.historyHint) return;
    historyHintCollapsed = !historyHintCollapsed;
    applyHistoryHintState();
    void chrome.storage.local.set({ [HISTORY_HINT_COLLAPSED_KEY]: historyHintCollapsed }).catch(() => {});
  }

  // 记忆闪卡 / 出题的浮窗按按钮位置在侧栏可视区内居中定位（放不下时翻到
  // 按钮下方），与 md 帮助气泡同法换算 ui-scale，避免浮窗被侧栏边缘裁切。
  function attachStudyTooltipPositioning() {
    const container = document.querySelector('.container');
    [['btnStudyFlashcards', 'studyFlashcardsTooltip'], ['btnStudyQuiz', 'studyQuizTooltip'], ['btnStudyMindmap', 'studyMindmapTooltip']].forEach(([buttonKey, tooltipKey]) => {
      const wrap = els[buttonKey]?.closest('.study-action-wrap');
      const tooltipEl = els[tooltipKey];
      if (!wrap || !tooltipEl || !container) return;
      const show = () => {
        tooltipEl.classList.add('study-action-tooltip-visible');
        tooltipEl.classList.remove('study-action-tooltip-below');
        const cRect = container.getBoundingClientRect();
        const scale = container.offsetWidth ? cRect.width / container.offsetWidth : 1;
        const tipW = tooltipEl.getBoundingClientRect().width / scale;
        const tipH = tooltipEl.getBoundingClientRect().height / scale;
        const cw = cRect.width / scale;
        const ch = cRect.height / scale;
        const wrapRect = wrap.getBoundingClientRect();
        let left = (wrapRect.left + wrapRect.width / 2 - tipW / 2) / scale;
        left = Math.max(4, Math.min(left, cw - tipW - 4));
        const arrowLeft = Math.max(10, Math.min((wrapRect.left + wrapRect.width / 2) / scale - left, tipW - 10));
        tooltipEl.style.setProperty('--study-tooltip-arrow', arrowLeft + 'px');
        let top = (wrapRect.top - tipH - 8) / scale;
        let below = false;
        if (top < 4) {
          top = (wrapRect.bottom + 8) / scale;
          below = true;
        }
        if (top + tipH > ch - 4) top = Math.max(4, ch - tipH - 4);
        tooltipEl.style.left = left + 'px';
        tooltipEl.style.top = top + 'px';
        tooltipEl.classList.toggle('study-action-tooltip-below', below);
      };
      const hide = () => {
        tooltipEl.classList.remove('study-action-tooltip-visible');
        tooltipEl.style.removeProperty('left');
        tooltipEl.style.removeProperty('top');
        tooltipEl.style.removeProperty('--study-tooltip-arrow');
      };
      wrap.addEventListener('mouseenter', show);
      wrap.addEventListener('mouseleave', hide);
      wrap.addEventListener('focusin', show);
      wrap.addEventListener('focusout', hide);
      els[buttonKey]?.addEventListener('click', hide);
    });
  }

  // 享字按钮浮窗：黑色样式，按按钮位置在侧栏可视区内定位，不被右边缘裁切。
  function attachShareTooltipPositioning() {
    const wrap = els.shareCommentWrap;
    const tooltipEl = els.shareCommentTooltip;
    const container = document.querySelector('.container');
    if (!wrap || !tooltipEl || !container) return;
    const show = () => {
      tooltipEl.classList.add('share-comment-tooltip-visible');
      tooltipEl.classList.remove('share-comment-tooltip-below');
      const cRect = container.getBoundingClientRect();
      const scale = container.offsetWidth ? cRect.width / container.offsetWidth : 1;
      const tipW = tooltipEl.getBoundingClientRect().width / scale;
      const tipH = tooltipEl.getBoundingClientRect().height / scale;
      const cw = cRect.width / scale;
      const ch = cRect.height / scale;
      const wrapRect = wrap.getBoundingClientRect();
      let left = (wrapRect.left + wrapRect.width / 2 - tipW / 2) / scale;
      left = Math.max(4, Math.min(left, cw - tipW - 4));
      const arrowLeft = Math.max(10, Math.min((wrapRect.left + wrapRect.width / 2) / scale - left, tipW - 10));
      tooltipEl.style.setProperty('--share-tooltip-arrow', arrowLeft + 'px');
      let top = (wrapRect.top - tipH - 8) / scale;
      let below = false;
      if (top < 4) {
        top = (wrapRect.bottom + 8) / scale;
        below = true;
      }
      if (top + tipH > ch - 4) top = Math.max(4, ch - tipH - 4);
      tooltipEl.style.left = left + 'px';
      tooltipEl.style.top = top + 'px';
      tooltipEl.classList.toggle('share-comment-tooltip-below', below);
    };
    const hide = () => {
      tooltipEl.classList.remove('share-comment-tooltip-visible');
      tooltipEl.style.removeProperty('left');
      tooltipEl.style.removeProperty('top');
      tooltipEl.style.removeProperty('--share-tooltip-arrow');
    };
    wrap.addEventListener('mouseenter', show);
    wrap.addEventListener('mouseleave', hide);
    wrap.addEventListener('focusin', show);
    wrap.addEventListener('focusout', hide);
    els.btnShareComment?.addEventListener('click', hide);
  }

  // ============ 手机端总结 ============
  const MOBILE_BETA_LOCKED_KEY = 'biliSummaryMobileBetaLocked';
  const MOBILE_BILI_UID_KEY = 'biliSummaryMobileBilibiliUid';
  const DEFAULT_SHARE_COMMENT_FOOTER = '——由 @user 和bilibili视频总结插件自动生成（插件官网 cetle.cn）';
  let mobileBotConfig = null;
  let mobileBoundAccount = null;
  let mobileFollowState = null;
  let mobileStateLoading = false;
  let mobileBetaAccess = { loaded: false, enabled: false, selected: false, isAdmin: false, allowed: false };
  let persistedMobileBetaLocked = false;
  let studyAgentConfig = { enabled: false, publicUrl: '' };

  function mobileSummaryHint(text) {
    if (els.mobileSummaryUidHint) els.mobileSummaryUidHint.textContent = text || '';
  }

  function renderMobileSummaryAccess() {
    if (!els.btnMobileSummary) return;
    const locked = mobileBetaAccess.loaded ? mobileBetaAccess.allowed !== true : persistedMobileBetaLocked;
    const baseLabel = currentLanguage === 'en' ? 'Mobile summary (Beta)' : (currentLanguage === 'zh-TW' ? '手機端總結（Beta）' : '手机端总结（Beta）');
    const label = locked ? `${baseLabel}${currentLanguage === 'en' ? ' — not available yet' : (currentLanguage === 'zh-TW' ? '暫未開放' : '暂未开放')}` : baseLabel;
    els.btnMobileSummary.classList.toggle('mobile-summary-entry-locked', locked);
    els.btnMobileSummary.setAttribute('aria-disabled', String(locked));
    els.btnMobileSummary.setAttribute('aria-label', label);
    els.btnMobileSummary.title = locked ? '' : baseLabel;
    if (els.mobileSummaryTooltip) {
      els.mobileSummaryTooltip.textContent = label;
      els.mobileSummaryTooltip.classList.toggle('hidden', !locked);
    }
  }

  async function fetchMobilePublicConfig() {
    try {
      const session = await requireLoggedInSession();
      const response = await fetch(defaultServerBaseUrl(session.serverBaseUrl) + '/api/mobile-summary/access', {
        cache: 'no-store',
        headers: { Authorization: 'Bearer ' + session.token },
      });
      const data = await response.json().catch(() => ({}));
      if (!response.ok || !data) throw new Error(data.error || '获取配置失败');
      mobileBetaAccess = {
        loaded: true,
        enabled: data.mobileBeta?.enabled === true,
        selected: data.mobileBeta?.selected === true,
        isAdmin: data.mobileBeta?.isAdmin === true,
        allowed: data.mobileBeta?.allowed === true,
      };
      mobileBotConfig = {
        enabled: data.enabled !== false,
        available: data.available !== false,
        botUid: String(data.botUid || ''),
        botName: String(data.botName || '').trim(),
        shareCommentFooter: String(data.shareCommentFooter || '').trim() || DEFAULT_SHARE_COMMENT_FOOTER,
        mobileApp: data.mobileApp || {},
      };
      persistedMobileBetaLocked = mobileBetaAccess.allowed !== true;
      chrome.storage.local.set({ [MOBILE_BETA_LOCKED_KEY]: persistedMobileBetaLocked }).catch(() => {});
      renderMobileSummaryAccess();
      if (!mobileBotConfig.botName) throw new Error('服务器未配置总结助手昵称');
      return mobileBotConfig;
    } catch (err) {
      mobileBetaAccess = { loaded: true, enabled: false, selected: false, isAdmin: false, allowed: false };
      mobileBotConfig = { enabled: false, available: false, botUid: '', botName: '', shareCommentFooter: DEFAULT_SHARE_COMMENT_FOOTER, mobileApp: {}, loadError: err.message || '获取配置失败' };
      persistedMobileBetaLocked = true;
      chrome.storage.local.set({ [MOBILE_BETA_LOCKED_KEY]: true }).catch(() => {});
      renderMobileSummaryAccess();
      return mobileBotConfig;
    }
  }

  async function refreshMobileSummaryAccess() {
    const previousName = mobileBotConfig?.botName || '';
    await fetchMobilePublicConfig();
    if (!els.mobileSummaryPanel?.classList.contains('hidden')) {
      renderMobileServiceStatus();
      renderMobileHow();
      renderMobileAppInfo();
      if (previousName && mobileBotConfig?.botName && previousName !== mobileBotConfig.botName) {
        mobileSummaryHint(`总结助手账号已更新为 @${mobileBotConfig.botName}`);
      }
      if (!mobileBetaAccess.allowed) els.mobileSummaryPanel.classList.add('hidden');
    }
    return mobileBetaAccess.allowed;
  }

  async function storedBilibiliAccount() {
    try {
      const stored = await chrome.storage.local.get(MOBILE_BILI_UID_KEY);
      const value = stored[MOBILE_BILI_UID_KEY];
      if (value?.unlinked) return null;
      if (value && Number(value.mid) > 0) return { mid: Number(value.mid), uname: String(value.uname || '') };
    } catch (e) {}
    return null;
  }

  async function mobileBilibiliUnlinked() {
    try {
      const stored = await chrome.storage.local.get(MOBILE_BILI_UID_KEY);
      return stored[MOBILE_BILI_UID_KEY]?.unlinked === true;
    } catch (e) {}
    return false;
  }

  async function accountFromCurrentPage() {
    if (!currentTab?.id) return null;
    try {
      const response = await sendMobileBilibiliMessage({ action: 'getBilibiliAccountInfo' });
      if (response?.success && response.data?.mid > 0) {
        return { mid: Number(response.data.mid), uname: String(response.data.uname || '') };
      }
    } catch (e) {}
    return null;
  }

  async function sendMobileBilibiliMessage(message) {
    if (!currentTab?.id) throw new Error('请先打开一个 B 站视频页');
    const sendToCurrentTab = () => Promise.race([
      chrome.tabs.sendMessage(currentTab.id, message),
      new Promise((_, reject) => setTimeout(() => reject(new Error('B 站页面响应超时，请刷新视频页后重试')), 10000)),
    ]);
    try {
      return await sendToCurrentTab();
    } catch (error) {
      if (String(error?.message || '').includes('响应超时')) throw error;
      const injected = await chrome.runtime.sendMessage({ action: 'injectContentScript', tabId: currentTab.id });
      if (!injected?.success) throw new Error(injected?.error || 'B 站页面连接失败');
      return sendToCurrentTab();
    }
  }

  async function syncMobileBilibiliUid(mid) {
    const response = await chrome.runtime.sendMessage({ action: 'setMobileBilibiliUid', mid });
    if (!response?.success) throw new Error(response?.error || '账号关联授权失败');
  }

  function renderMobileBoundText(account) {
    if (!els.mobileSummaryBoundText) return;
    mobileBoundAccount = account || null;
    if (els.btnDetectUid) els.btnDetectUid.textContent = account ? '取消关联' : '使用当前 B 站账号';
    if (!account) {
      els.mobileSummaryBoundText.innerHTML = '<span class="mobile-summary-account">尚未关联 B 站账号</span>';
      return;
    }
    els.mobileSummaryBoundText.innerHTML = `<span class="mobile-summary-account">${escapeHtml(account.uname || 'B站用户')}</span>（uid：${Number(account.mid)}）`;
  }

  async function refreshMobileFollowState() {
    if (!els.btnFollowBot) return;
    els.btnFollowBot.disabled = true;
    mobileFollowState = null;
    if (!mobileBotConfig?.available) {
      renderFollowButton(false, false);
      mobileSummaryHint('评论区总结助手正在维护，请稍后再试');
      return;
    }
    if (!mobileBotConfig?.botUid) {
      renderFollowButton(false, false);
      mobileSummaryHint('总结助手账号尚未就绪，请稍后再来~');
      return;
    }
    if (!currentTab?.id) {
      renderFollowButton(false, false);
      mobileSummaryHint('请先打开一个 B 站视频页，关注需要 B 站登录态');
      return;
    }
    try {
      const response = await sendMobileBilibiliMessage({ action: 'getBilibiliRelation', fid: mobileBotConfig.botUid });
      mobileFollowState = response?.success ? !!response.data?.following : null;
      renderFollowButton(!!mobileFollowState, mobileFollowState !== null);
      if (mobileFollowState === false) {
        mobileSummaryHint('关注后 @ 总结助手更方便找到');
      }
    } catch (err) {
      renderFollowButton(false, true);
      mobileSummaryHint('暂时无法读取关注状态，仍可点击“关注”重试');
    }
  }

  function renderFollowButton(following, known) {
    if (!els.btnFollowBot) return;
    els.btnFollowBot.classList.toggle('mobile-summary-followed', !!following);
    els.btnFollowBot.textContent = following ? '已关注' : '关注';
    els.btnFollowBot.disabled = !known || following;
  }

  async function followBotAccount() {
    if (!mobileBotConfig?.botUid) { mobileSummaryHint('总结助手账号尚未就绪，请稍后再来~'); return; }
    if (!currentTab?.id) { mobileSummaryHint('请先打开一个 B 站视频页'); return; }
    els.btnFollowBot.disabled = true;
    els.btnFollowBot.textContent = '关注中…';
    els.btnFollowBot.setAttribute('aria-busy', 'true');
    mobileSummaryHint('正在关注总结助手…');
    try {
      const response = await sendMobileBilibiliMessage({ action: 'followBilibiliUser', fid: mobileBotConfig.botUid });
      if (!response?.success) throw new Error(response?.error || '关注失败');
      mobileFollowState = true;
      renderFollowButton(true, true);
      mobileSummaryHint('');
    } catch (err) {
      mobileSummaryHint('关注失败：' + (err.message || '请检查 B 站登录'));
      renderFollowButton(false, true);
    } finally {
      els.btnFollowBot.removeAttribute('aria-busy');
    }
  }

  async function detectBilibiliUid({ force = false } = {}) {
    if (!currentTab?.id) {
      mobileSummaryHint('请先打开一个 B 站视频页，再自动检测 uid');
      return;
    }
    const account = await accountFromCurrentPage();
    if (!account) {
      mobileSummaryHint('未检测到已登录的 B 站账号，请先登录 B 站');
      return;
    }
    try {
      await syncMobileBilibiliUid(account.mid);
      await chrome.storage.local.set({ [MOBILE_BILI_UID_KEY]: { mid: account.mid, uname: account.uname, detectedAt: Date.now() } });
    } catch (e) {
      mobileSummaryHint('账号检测成功，但关联授权失败：' + (e.message || '请重试'));
      return;
    }
    renderMobileBoundText(account);
    mobileSummaryHint(`已授权关联 B 站账号：${account.uname}（uid：${account.mid}）`);
    refreshMobileFollowState();
  }

  function saveManualBilibiliUid() {
    const raw = String(els.mobileSummaryUidInput?.value || '').trim();
    const mid = Number(raw);
    if (!/^\d{1,16}$/.test(raw) || mid <= 0) {
      mobileSummaryHint('请输入正确的 B 站 uid（纯数字）');
      return;
    }
    const apply = async (name) => {
      try {
        await syncMobileBilibiliUid(mid);
        await chrome.storage.local.set({ [MOBILE_BILI_UID_KEY]: { mid, uname: name, detectedAt: Date.now() } });
      } catch (e) {
        mobileSummaryHint('授权关联失败：' + (e.message || '请重试'));
        return;
      }
      renderMobileBoundText({ mid, uname: name || 'B站用户' });
      mobileSummaryHint(`已授权关联 B 站账号：${name || 'uid ' + mid}（uid：${mid}）`);
      els.mobileSummaryManualRow.classList.add('hidden');
      els.mobileSummaryUidInput.value = '';
      refreshMobileFollowState();
    };
    if (currentTab?.id) {
      sendMobileBilibiliMessage({ action: 'getBilibiliUserCard', mid }).then((response) => {
        void apply(response?.success ? String(response.data?.name || '') : '');
      }).catch(() => { void apply(''); });
    } else {
      void apply('');
    }
  }

  async function unlinkBilibiliUid() {
    if (!els.btnDetectUid) return;
    els.btnDetectUid.disabled = true;
    mobileSummaryHint('正在取消关联…');
    try {
      const response = await chrome.runtime.sendMessage({ action: 'clearMobileBilibiliUid' });
      if (!response?.success) throw new Error(response?.error || '取消关联失败');
      await chrome.storage.local.set({ [MOBILE_BILI_UID_KEY]: { unlinked: true, at: Date.now() } });
      renderMobileBoundText(null);
      mobileSummaryHint('已取消关联，@ 总结助手将不再生效，页面也不再显示你的 B 站账号');
    } catch (err) {
      renderMobileBoundText(await storedBilibiliAccount());
      mobileSummaryHint('取消关联失败：' + (err.message || '请重试'));
    } finally {
      if (els.btnDetectUid) els.btnDetectUid.disabled = false;
    }
  }

  async function loadMobileOsVotes() {
    try {
      const session = await requireLoggedInSession();
      const response = await fetch(defaultServerBaseUrl(session.serverBaseUrl) + '/api/mobile-summary/votes', {
        headers: { Authorization: 'Bearer ' + session.token },
      });
      const data = await response.json().catch(() => ({}));
      if (response.ok) renderMobileOsVotes(data);
    } catch (e) {}
  }

  function renderMobileOsVotes(data) {
    const ios = Math.max(0, Number(data?.ios || 0));
    const android = Math.max(0, Number(data?.android || 0));
    if (els.osIosCount) els.osIosCount.textContent = ios ? ios + '人' : '';
    if (els.osAndroidCount) els.osAndroidCount.textContent = android ? android + '人' : '';
    if (els.btnOsIos) els.btnOsIos.classList.toggle('mobile-summary-os-chosen', data?.mine === 'ios');
    if (els.btnOsAndroid) els.btnOsAndroid.classList.toggle('mobile-summary-os-chosen', data?.mine === 'android');
    const total = ios + android;
    if (els.mobileSummaryVoteBar) {
      if (total > 0) {
        els.mobileSummaryVoteBar.classList.remove('hidden');
        const iosRatio = Math.round((ios / total) * 100);
        const androidRatio = 100 - iosRatio;
        if (els.voteBarIos) els.voteBarIos.style.width = (iosRatio || (ios > 0 ? 1 : 0)) + '%';
        if (els.voteBarAndroid) els.voteBarAndroid.style.width = androidRatio + '%';
      } else {
        els.mobileSummaryVoteBar.classList.add('hidden');
      }
    }
  }

  function renderMobileAppInfo() {
    const app = mobileBotConfig?.mobileApp || {};
    const percent = Math.max(0, Math.min(100, Math.floor(Number(app.progressPercent) || 0)));
    if (els.mobileAppProgress) els.mobileAppProgress.textContent = percent + '%';
    if (els.mobileAppProgressFill) els.mobileAppProgressFill.style.width = percent + '%';
    const note = String(app.progressNote || '').trim();
    if (els.mobileAppProgressNote) {
      els.mobileAppProgressNote.textContent = note;
      els.mobileAppProgressNote.classList.toggle('hidden', !note);
    }
    const link = String(app.progressLink || '').trim();
    if (els.mobileAppProgressLink) {
      if (link) {
        els.mobileAppProgressLink.href = link;
        els.mobileAppProgressLink.textContent = link;
        els.mobileAppProgressLink.classList.remove('hidden');
      } else {
        els.mobileAppProgressLink.removeAttribute('href');
        els.mobileAppProgressLink.textContent = '';
        els.mobileAppProgressLink.classList.add('hidden');
      }
    }
  }

  function renderMobileHow() {
    if (!els.mobileSummaryHow) return;
    const botName = escapeHtml(mobileBotConfig?.botName || '');
    if (!mobileBotConfig?.available) {
      els.mobileSummaryHow.innerHTML = '<div class="mobile-summary-how-note mobile-summary-how-error">评论区总结助手正在维护，恢复后本页会自动显示使用步骤</div>';
      return;
    }
    if (!botName) {
      els.mobileSummaryHow.innerHTML = '<div class="mobile-summary-how-note mobile-summary-how-error">暂时无法读取总结助手账号，请稍后重新打开本页</div>';
      return;
    }
    els.mobileSummaryHow.innerHTML = `<div class="mobile-summary-how-step"><span>1</span><span>在有字幕的视频评论区输入 @<strong>${botName}</strong>，并从弹出的账号候选中点选助手后发布</span></div><div class="mobile-summary-how-step"><span>2</span><span>等待约一分钟，助手会回复不超过 800 字的总结；若原楼层被 B 站隐藏，会改发一条 @你的顶层评论，发布完成后扣除本站学分</span></div><div class="mobile-summary-how-note">暂不支持无字幕视频 · <span class="mobile-summary-lookup">查看识别方法</span></div>`;
  }

  function renderMobileServiceStatus() {
    if (!els.mobileSummaryServiceStatus) return;
    const available = mobileBotConfig?.available === true;
    els.mobileSummaryServiceStatus.textContent = available ? '有效' : '暂时失效';
    els.mobileSummaryServiceStatus.classList.toggle('mobile-summary-service-available', available);
    els.mobileSummaryServiceStatus.classList.toggle('mobile-summary-service-unavailable', !available);
  }

  function openMobileSummaryModal() {
    if (els.mobileSummaryModal) els.mobileSummaryModal.classList.remove('hidden');
  }

  function closeMobileSummaryModal() {
    if (els.mobileSummaryModal) els.mobileSummaryModal.classList.add('hidden');
  }

  async function voteMobileOs(platform) {
    try {
      const session = await requireLoggedInSession();
      const response = await fetch(defaultServerBaseUrl(session.serverBaseUrl) + '/api/mobile-summary/vote', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + session.token },
        body: JSON.stringify({ platform }),
      });
      const data = await response.json().catch(() => ({}));
      if (!response.ok) throw new Error(data.error || '投票失败');
      renderMobileOsVotes(data);
      showToast(platform === 'ios' ? '已选择 iOS，感谢参与~' : '已选择 Android，感谢参与~', 'success');
    } catch (err) {
      showToast('投票失败: ' + (err.message || '请稍后重试'), 'error');
    }
  }

  async function openMobileSummary() {
    if (!await refreshMobileSummaryAccess()) {
      return;
    }
    els.settingsPanel.classList.add('hidden');
    els.collectionPanel.classList.add('hidden');
    els.toolboxPanel.classList.add('hidden');
    els.historyPanel.classList.add('hidden');
    els.styleManagerPanel.classList.add('hidden');
    els.mobileSummaryPanel.classList.remove('hidden');
    mobileSummaryHint('');
    if (mobileStateLoading) return;
    mobileStateLoading = true;
    try {
      await fetchStudyAgentConfig();
      renderMobileServiceStatus();
      renderMobileHow();
      renderMobileAppInfo();
      const account = await storedBilibiliAccount();
      const unlinked = await mobileBilibiliUnlinked();
      const currentPageAccount = await accountFromCurrentPage();
      renderMobileBoundText(account);
      if (!account && currentPageAccount && !unlinked) {
        mobileSummaryHint(`检测到 B 站账号 ${currentPageAccount.uname || ''}（uid：${currentPageAccount.mid}），点击“使用当前 B 站账号”完成授权绑定后即可在评论区 @总结`);
      } else {
        if (account && currentPageAccount && Number(account.mid) !== Number(currentPageAccount.mid)) {
          mobileSummaryHint(`当前视频页登录的是 ${currentPageAccount.uname || '另一个账号'}（uid：${currentPageAccount.mid}），如需切换关联请点击“使用当前 B 站账号”`);
        }
      }
      refreshMobileFollowState();
      loadMobileOsVotes();
    } finally {
      mobileStateLoading = false;
    }
  }

  function videoSessionKey(data, tab = currentTab) {
    return data?.bvid && tab?.id ? [tab.id, data.bvid, data.cid || ''].join(':') : '';
  }

  function saveCurrentVideoSession() {
    if (!currentTab?.id || !videoData?.bvid) return;
    const sessionKey = videoSessionKey(videoData, currentTab);
    const session = activeVideoSession?.key === sessionKey ? activeVideoSession : {};
    Object.assign(session, {
      key: sessionKey,
      tab: { id: currentTab.id, title: currentTab.title, url: currentTab.url },
      videoData: { ...videoData },
      lastSubtitleText,
      subtitleSegments: [...subtitleSegments],
      lastSummary,
      lastSummaryTranscript,
      subtitleResultMeta: subtitleResultMeta ? { ...subtitleResultMeta } : null,
      summaryResultMeta: summaryResultMeta ? { ...summaryResultMeta } : null,
      currentResultMode,
      lastSubtitleWasTranscribedAudio,
      isProcessing,
      isAudioTranscribing,
      slowAsrQueued,
      audioProgressMessage: session.audioProgressMessage || '',
      audioProgressDetail: session.audioProgressDetail || '',
      audioSummaryJobId: session.audioSummaryJobId || '',
      runId: Number(session.runId || 0),
      updatedAt: Date.now(),
    });
    videoSessions.set(sessionKey, session);
    activeVideoSession = session;
    renderHistoryList();
    return session;
  }

  async function loadSummaryHistory() {
    beginHistoryLoading();
    try {
      if (!accountSettingsUserId) {
        summaryHistory = [];
        renderHistoryList();
        return;
      }
      const db = await openCollectionHandleDb();
      const records = await new Promise((resolve, reject) => {
        const request = db.transaction(SUMMARY_HISTORY_STORE_NAME, 'readonly').objectStore(SUMMARY_HISTORY_STORE_NAME).getAll();
        request.onsuccess = () => resolve(request.result || []);
        request.onerror = () => reject(request.error || new Error('Could not read summary history'));
      });
      summaryHistory = records
        .filter((record) => record.userId === accountSettingsUserId && record.lastSummary?.trim())
        .sort((a, b) => b.updatedAt - a.updatedAt)
        .slice(0, SUMMARY_HISTORY_LIMIT);
    } catch (err) {
      console.warn('[BiliSummary] Could not restore summary history:', err);
      summaryHistory = [];
    } finally {
      endHistoryLoading();
    }
    renderHistoryList();
    updateSummaryCommentButton();
  }

  async function persistCompletedSummary(completedSession = activeVideoSession, historyId = '') {
    const historyUserId = String(completedSession?.userId || '');
    if (!historyUserId || !completedSession?.videoData?.bvid || !completedSession.lastSummary?.trim()) return;
    const updatedAt = Date.now();
    const session = {
      id: historyId || [historyUserId, completedSession.videoData.bvid, completedSession.videoData.cid || '', updatedAt, crypto.randomUUID()].join(':'),
      userId: historyUserId,
      tab: { ...completedSession.tab },
      videoData: { ...completedSession.videoData },
      lastSubtitleText: completedSession.lastSubtitleText || '',
      subtitleSegments: [...(completedSession.subtitleSegments || [])],
      lastSummary: completedSession.lastSummary,
      lastSummaryTranscript: completedSession.lastSummaryTranscript || '',
      subtitleResultMeta: completedSession.subtitleResultMeta ? { ...completedSession.subtitleResultMeta } : null,
      summaryResultMeta: completedSession.summaryResultMeta ? { ...completedSession.summaryResultMeta } : null,
      currentResultMode: 'summary',
      lastSubtitleWasTranscribedAudio: !!completedSession.lastSubtitleWasTranscribedAudio,
      updatedAt,
    };
    const visibleForCurrentAccount = historyUserId === accountSettingsUserId;
    const removed = [];
    if (visibleForCurrentAccount) {
      const existingIndex = summaryHistory.findIndex((record) => record.id === session.id);
      if (existingIndex >= 0) summaryHistory.splice(existingIndex, 1);
      summaryHistory.unshift(session);
      removed.push(...summaryHistory.splice(SUMMARY_HISTORY_LIMIT));
      renderHistoryList();
    }
    try {
      const db = await openCollectionHandleDb();
      await new Promise((resolve, reject) => {
        const transaction = db.transaction(SUMMARY_HISTORY_STORE_NAME, 'readwrite');
        const store = transaction.objectStore(SUMMARY_HISTORY_STORE_NAME);
        store.put(session);
        removed.forEach((record) => store.delete(record.id));
        transaction.oncomplete = () => resolve();
        transaction.onerror = () => reject(transaction.error || new Error('Could not save summary history'));
        transaction.onabort = () => reject(transaction.error || new Error('Could not save summary history'));
      });
      return true;
    } catch (err) {
      console.warn('[BiliSummary] Could not save summary history:', err);
      return false;
    }
  }

  async function acknowledgePendingSummaryResults(session, ids) {
    if (!ids.length) return;
    const response = await fetch(defaultServerBaseUrl(session.serverBaseUrl) + '/api/summary-results/ack', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + session.token },
      body: JSON.stringify({ ids }),
    });
    if (!response.ok) throw new Error('Could not acknowledge restored summaries');
  }

  // 历史加载期间（含服务器留存的总结回传）锁住开始总结与音频总结按钮；
  // 两个来源都完成后才放行；服务器卡住时看门狗兜底放行，避免按钮被长期锁死。
  const HISTORY_LOAD_WATCHDOG_MS = 6000;
  const HISTORY_SYNC_FETCH_TIMEOUT_MS = 8000;

  function beginHistoryLoading() {
    historyLoadingDepth += 1;
    historyLoaded = false;
    updateStartButtonIdle();
    if (historyLoadingWatchdog === null) {
      historyLoadingWatchdog = setTimeout(() => {
        historyLoadingWatchdog = null;
        historyLoadingDepth = 0;
        historyLoaded = true;
        updateStartButtonIdle();
      }, HISTORY_LOAD_WATCHDOG_MS);
    }
  }

  function endHistoryLoading() {
    historyLoadingDepth = Math.max(0, historyLoadingDepth - 1);
    if (historyLoadingDepth === 0) {
      if (historyLoadingWatchdog !== null) {
        clearTimeout(historyLoadingWatchdog);
        historyLoadingWatchdog = null;
      }
      historyLoaded = true;
      updateStartButtonIdle();
    }
  }

  async function syncPendingSummaryResults() {
    beginHistoryLoading();
    try {
      if (!accountSettingsUserId) return;
      const session = await raceWithTimeout(requireLoggedInSession(), HISTORY_SYNC_FETCH_TIMEOUT_MS);
      if (!session) return;
      const controller = new AbortController();
      const fetchTimer = setTimeout(() => controller.abort(), HISTORY_SYNC_FETCH_TIMEOUT_MS);
      let response;
      try {
        response = await fetch(defaultServerBaseUrl(session.serverBaseUrl) + '/api/summary-results/pending', {
          headers: { Authorization: 'Bearer ' + session.token },
          signal: controller.signal,
        });
      } finally {
        clearTimeout(fetchTimer);
      }
      const data = await response.json().catch(() => ({}));
      if (!response.ok) throw new Error(data.error || 'Could not restore completed summaries');
      const acknowledged = [];
      for (const result of Array.isArray(data.results) ? data.results : []) {
        const resultMeta = { title: result.title || '', bvid: result.bvid || '', cid: result.cid || '', url: result.url || '' };
        const pages = Array.isArray(result.pages) ? result.pages.map((page) => ({
          cid: String(page?.cid || ''), page: Number(page?.page || 1), part: String(page?.part || ''), duration: Number(page?.duration || 0),
        })) : [];
        const currentPage = pages.find((page) => String(page.cid || '') === String(result.cid || ''));
        const restored = {
          userId: accountSettingsUserId,
          tab: { title: result.title || '', url: result.url || '' },
          videoData: {
            bvid: result.bvid || '', cid: result.cid || '', title: result.title || '', owner: result.owner || '',
            duration: Number(result.duration_seconds || 0), pic: result.pic || '', pages,
            page: Number(currentPage?.page || result.page || 1), part: currentPage?.part || result.part || '',
          },
          lastSubtitleText: result.transcript || '',
          subtitleSegments: [],
          lastSummary: result.summary || '',
          lastSummaryTranscript: result.transcript || '',
          subtitleResultMeta: resultMeta,
          summaryResultMeta: resultMeta,
          lastSubtitleWasTranscribedAudio: false,
        };
        if (await persistCompletedSummary(restored, result.id)) acknowledged.push(result.id);
      }
      await acknowledgePendingSummaryResults(session, acknowledged);
    } catch (err) {
      console.warn('[BiliSummary] Could not sync completed summaries:', err);
    } finally {
      endHistoryLoading();
    }
  }

  // 后台音频总结任务若在侧栏关闭期间失败（如学分不足、字数超限），服务器不会
  // 回传历史，这里在打开侧栏时把失败原因一次性提示给用户并清理积压任务。
  const AUDIO_JOB_FAILURES_NOTIFIED_KEY = 'biliSummaryAudioFailuresNotified';

  async function notifyFailedAudioSummaryJobs() {
    try {
      const response = await chrome.runtime.sendMessage({ action: 'getAudioSummaryJobs' }).catch(() => null);
      const jobs = Array.isArray(response?.jobs) ? response.jobs : [];
      const failedJobs = jobs.filter((job) => job?.status === 'failed');
      if (!failedJobs.length) return;
      const stored = await chrome.storage.local.get({ [AUDIO_JOB_FAILURES_NOTIFIED_KEY]: [] }).catch(() => ({}));
      const seen = new Set(Array.isArray(stored[AUDIO_JOB_FAILURES_NOTIFIED_KEY]) ? stored[AUDIO_JOB_FAILURES_NOTIFIED_KEY] : []);
      const toNotify = failedJobs.filter((job) => !seen.has(job.id));
      if (!toNotify.length) return;
      const first = toNotify[0];
      const suffix = toNotify.length > 1 ? `（共 ${toNotify.length} 个任务失败）` : '';
      showToast(`❌ 音频总结失败：${first.error || '未知原因，请重试'}${suffix}`, 'error', 8000);
      await chrome.storage.local.set({
        [AUDIO_JOB_FAILURES_NOTIFIED_KEY]: [...seen, ...toNotify.map((job) => job.id)].slice(-50),
      }).catch(() => {});
      for (const job of toNotify) {
        void chrome.runtime.sendMessage({ action: 'ackAudioSummaryJob', jobId: job.id }).catch(() => {});
      }
    } catch (_) {}
  }

  async function findHistorySummaryRecord(bvid, cid) {
    if (!accountSettingsUserId || !bvid) return null;
    try {
      const db = await openCollectionHandleDb();
      const records = await new Promise((resolve, reject) => {
        const request = db.transaction(SUMMARY_HISTORY_STORE_NAME, 'readonly').objectStore(SUMMARY_HISTORY_STORE_NAME).getAll();
        request.onsuccess = () => resolve(request.result || []);
        request.onerror = () => reject(request.error || new Error('Could not read summary history'));
      });
      return records
        .filter((record) => (
          String(record.userId) === String(accountSettingsUserId)
          && record.videoData?.bvid === bvid
          && String(record.videoData?.cid || '') === String(cid || '')
          && record.lastSummary?.trim()
        ))
        .sort((a, b) => b.updatedAt - a.updatedAt)[0] || null;
    } catch (err) {
      console.warn('[BiliSummary] Could not find history summary:', err);
      return null;
    }
  }

  function renderHistoryList() {
    if (!els.historyList) return;
    const sessions = summaryHistory;
    els.historyList.textContent = '';
    if (!sessions.length) {
      els.historyList.textContent = t('emptySummaryHistory');
      return;
    }
    sessions.forEach((session) => {
      const row = document.createElement('div');
      row.className = 'history-item';
      const open = document.createElement('button');
      open.type = 'button';
      open.className = 'history-item-open';
      open.title = t('historyOpenSummary');
      open.addEventListener('click', () => restoreVideoSession(session));
      const titleLink = document.createElement('a');
      titleLink.className = 'history-item-title';
      const videoUrl = session.tab?.url || buildVideoUrl(session.videoData);
      if (videoUrl) {
        titleLink.href = videoUrl;
        titleLink.target = '_blank';
        titleLink.rel = 'noopener';
        titleLink.title = t('historyOpenVideo');
        titleLink.addEventListener('click', (event) => event.stopPropagation());
      }
      titleLink.textContent = session.videoData?.title || session.tab?.title || session.videoData?.bvid || '未命名视频';
      open.appendChild(titleLink);
      row.appendChild(open);
      const remove = document.createElement('button');
      remove.type = 'button';
      remove.className = 'history-item-delete';
      remove.title = t('historyDelete');
      remove.textContent = '✕';
      remove.addEventListener('click', () => {
        void deleteSummaryHistory(session);
      });
      row.appendChild(remove);
      els.historyList.appendChild(row);
    });
  }

  function buildVideoUrl(videoDataLike) {
    if (!videoDataLike?.bvid) return '';
    const base = 'https://www.bilibili.com/video/' + videoDataLike.bvid;
    return videoDataLike.page > 1 || videoDataLike.pages?.length > 1 ? base + '?p=' + (videoDataLike.page || 1) : base;
  }

  async function clearRemoteVideoTranscriptionCache(bvid, cid) {
    const session = await requireLoggedInSession();
    const url = `${defaultServerBaseUrl(session.serverBaseUrl)}/api/asr/transcriptions/cache?bvid=${encodeURIComponent(bvid)}&cid=${encodeURIComponent(cid)}`;
    const response = await fetch(url, {
      method: 'DELETE',
      headers: { Authorization: 'Bearer ' + session.token },
    });
    const data = await response.json().catch(() => ({}));
    if (!response.ok || data.cleared !== true) throw new Error(data.error || '服务器转写缓存清理失败');
  }

  async function deleteLocalVideoHistoryRecords(bvid, cid) {
    const db = await openCollectionHandleDb();
    await new Promise((resolve, reject) => {
      const transaction = db.transaction(SUMMARY_HISTORY_STORE_NAME, 'readwrite');
      const store = transaction.objectStore(SUMMARY_HISTORY_STORE_NAME);
      const request = store.getAll();
      request.onsuccess = () => {
        for (const entry of request.result || []) {
          if (String(entry?.userId || '') !== String(accountSettingsUserId)) continue;
          if (String(entry?.videoData?.bvid || '') !== bvid || String(entry?.videoData?.cid || '') !== cid) continue;
          store.delete(entry.id);
        }
      };
      request.onerror = () => transaction.abort();
      transaction.oncomplete = () => resolve();
      transaction.onerror = () => reject(transaction.error || new Error('Could not delete video summary history'));
      transaction.onabort = () => reject(transaction.error || request.error || new Error('Could not delete video summary history'));
    });
  }

  async function reloadDeletedVideoSubtitle(bvid, cid) {
    if (!currentTab?.id || String(videoData?.bvid || '') !== bvid || String(videoData?.cid || '') !== cid) return;
    const detectionId = ++videoDetectionId;
    isSubtitleLoading = true;
    els.result.classList.remove('hidden');
    updateResultTabs('subtitle');
    els.subtitleView.textContent = '正在重新检查这个视频的字幕...';
    els.btnStart.disabled = true;
    renderFollowUpDock();
    await chrome.tabs.sendMessage(currentTab.id, {
      action: 'clearVerifiedSubtitleCache', bvid, cid,
    }).catch(() => null);
    try {
      await loadSubtitleDirect({ maxAttempts: 5, detectionId });
    } finally {
      if (detectionId === videoDetectionId && !isAudioTranscribing) {
        isSubtitleLoading = false;
        updateStartButtonIdle();
        settleSubtitleNotFoundIfStuck(detectionId);
      }
    }
  }

  async function deleteSummaryHistory(record) {
    const recordId = record?.id;
    const deletedBvid = String(record?.videoData?.bvid || '');
    const deletedCid = String(record?.videoData?.cid || '');
    if (!recordId || !deletedBvid || !deletedCid) return;
    const deletingVisibleVideo = deletedBvid
      && String(videoData?.bvid || '') === deletedBvid
      && String(videoData?.cid || '') === deletedCid;
    try {
      // 先清服务端；若网络失败就保留本地历史，避免 UI 声称已清空但下次仍命中旧 ASR。
      await clearRemoteVideoTranscriptionCache(deletedBvid, deletedCid);
      await deleteLocalVideoHistoryRecords(deletedBvid, deletedCid);
      if (record?.tab?.id) {
        await chrome.tabs.sendMessage(record.tab.id, {
          action: 'clearVerifiedSubtitleCache', bvid: deletedBvid, cid: deletedCid,
        }).catch(() => null);
      }
      summaryHistory = summaryHistory.filter((entry) => !(
        String(entry?.userId || '') === String(accountSettingsUserId)
        && String(entry?.videoData?.bvid || '') === deletedBvid
        && String(entry?.videoData?.cid || '') === deletedCid
      ));
      for (const [key, session] of videoSessions.entries()) {
        if (String(session?.videoData?.bvid || '') !== deletedBvid || String(session?.videoData?.cid || '') !== deletedCid) continue;
        session.runId = ++summaryRunId;
        if (session.audioRequestId) {
          void chrome.runtime.sendMessage({
            action: 'cancelVideoAudioTranscription',
            requestId: session.audioRequestId,
            jobId: session.audioSummaryJobId,
            tabId: session.tab?.id,
            bvid: deletedBvid,
            cid: deletedCid,
          }).catch(() => {});
        }
        videoSessions.delete(key);
      }
      if (deletingVisibleVideo) clearDeletedVideoResult();
      renderHistoryList();
      showToast(t('historyDeleted'));
      if (deletingVisibleVideo) await reloadDeletedVideoSubtitle(deletedBvid, deletedCid);
    } catch (err) {
      console.warn('[BiliSummary] Could not delete summary history:', err);
      showToast(`删除失败：${err.message || '请稍后重试'}`, 'error', 5000);
    }
  }

  function clearDeletedVideoResult() {
    if (activeVideoSession) activeVideoSession.runId = ++summaryRunId;
    activeVideoSession = null;
    isProcessing = false;
    isAudioTranscribing = false;
    slowAsrQueued = false;
    isSubtitleLoading = false;
    lastSubtitleText = '';
    subtitleSegments = [];
    lastSubtitleWasTranscribedAudio = false;
    lastSummary = '';
    lastSummaryTranscript = '';
    subtitleResultMeta = null;
    summaryResultMeta = null;
    estimatedSummaryCredits = 0;
    followUpHistory = [];
    stopSubtitleProgressiveRender();
    closeSubtitleSearch();
    subtitleViewCache = null;
    if (videoData) {
      videoData = { ...videoData };
      delete videoData.hasSubtitle;
      delete videoData.subtitleInfo;
    }
    els.summaryView.textContent = '';
    els.result.classList.remove('hidden');
    updateResultTabs('subtitle');
    els.subtitleView.textContent = '已清空这个视频的总结和字幕。';
    els.btnStart.disabled = true;
    updateStartButtonIdle();
    renderFollowUpDock();
    updateStudyActionState();
    updateSummaryCommentButton();
  }

  function openHistoryPanel() {
    saveCurrentVideoSession();
    els.settingsPanel.classList.add('hidden');
    els.collectionPanel.classList.add('hidden');
    els.toolboxPanel.classList.add('hidden');
    els.styleManagerPanel.classList.add('hidden');
    els.mobileSummaryPanel.classList.add('hidden');
    els.historyPanel.classList.remove('hidden');
    renderHistoryList();
  }

  function restoreVideoSession(session) {
    if (!session?.videoData) return;
    currentTab = session.tab;
    videoData = session.videoData;
    lastSubtitleText = session.lastSubtitleText || '';
    subtitleSegments = Array.isArray(session.subtitleSegments) ? session.subtitleSegments : [];
    lastSummary = session.lastSummary || '';
    lastSummaryTranscript = session.lastSummaryTranscript || '';
    subtitleResultMeta = session.subtitleResultMeta || createResultMetadata(session.videoData, session.tab);
    summaryResultMeta = session.summaryResultMeta || createResultMetadata(session.videoData, session.tab);
    lastSubtitleWasTranscribedAudio = !!session.lastSubtitleWasTranscribedAudio;
    isProcessing = !!session.isProcessing;
    isAudioTranscribing = !!session.isAudioTranscribing;
    slowAsrQueued = !!session.slowAsrQueued;
    activeVideoSession = session;
    els.historyPanel.classList.add('hidden');
    showVideoInfo(videoData);
    els.statusBar.classList.add('hidden');
    els.controls.classList.remove('hidden');
    els.result.classList.remove('hidden');
    if (isAudioTranscribing) showSubtitleLoading(session.audioProgressMessage || t('transcribingAudio'), session.audioProgressDetail || '');
    else if (isProcessing && lastSummary) {
      updateResultTabs('summary');
      renderStreamingSummary(lastSummary);
    } else if (isProcessing) showSummaryLoading(t('generatingInSummary'));
    else if (session.error && lastSummary) {
      showResult(lastSummary);
      setStatus('error', '❌', session.error);
    } else if (session.error) {
      showSubtitleFailure('任务未完成，请重试');
      setStatus('error', '❌', session.error);
    }
    else if (session.currentResultMode === 'summary' && lastSummary) showResult(lastSummary);
    else if (lastSubtitleText) renderSubtitleText(lastSubtitleText, { follow: true });
    else if (lastSummary) showResult(lastSummary);
    else showSubtitleNotFound();
    els.btnStart.disabled = !historyLoaded || isProcessing || isAudioTranscribing;
    if (isProcessing || isAudioTranscribing) updateStartButtonProcessing();
    else if (lastSummary) updateStartButtonSuccess();
    else updateStartButtonIdle();
    const restoredTranscript = studySourceText();
    if (restoredTranscript.trim()) estimatedSummaryCredits = getEstimatedSummaryCredits(restoredTranscript);
    updateStudyActionState();
    renderHistoryList();
  }

  function isActiveSession(session) {
    return Boolean(session && activeVideoSession === session && videoSessionKey(videoData, currentTab) === session.key);
  }

  function scheduleActiveTabDetection() {
    if (disableRealtimePageSwitch) return;
    clearTimeout(tabSwitchTimer);
    tabSwitchTimer = setTimeout(() => {
      detectVideo({ waitForSubtitle: false });
    }, 100);
  }

  const OPEN_DASHBOARD_SESSION_TIMEOUT_MS = 2500;

  function raceWithTimeout(promise, ms) {
    return Promise.race([
      promise,
      new Promise((resolve) => setTimeout(() => resolve(null), ms)),
    ]);
  }

  async function openDashboardNextToCurrent(path = '/', adjacentTab = null) {
    let session = null;
    if (path === '/' || path === '') {
      // 登录态查询绝不能阻塞打开官网：后台/服务端卡住时 2.5 秒后先开标签页，
      // 官网页面本身会校验并把已验证的会话回同步给插件（见 content/dashboard.js）。
      session = await raceWithTimeout(requireLoggedInSession().catch(() => null), OPEN_DASHBOARD_SESSION_TIMEOUT_MS);
    } else {
      session = await requireLoggedInSession().catch(() => null);
    }
    const saved = session ? {} : await chrome.storage.local.get({ serverBaseUrl: '' });
    const url = defaultServerBaseUrl(session?.serverBaseUrl || saved.serverBaseUrl);
    if (!url) throw new Error('请先设置后端地址');
    const device = await raceWithTimeout(chrome.runtime.sendMessage({ action: 'getInstallationDeviceId' }).catch(() => null), OPEN_DASHBOARD_SESSION_TIMEOUT_MS);
    const deviceHash = device?.success ? '&device=' + encodeURIComponent(device.deviceId) : '';
    const authHash = '#' + (session?.token ? 'token=' + encodeURIComponent(session.token) + '&' : '') + 'ext=' + encodeURIComponent(chrome.runtime.id) + deviceHash;
    const targetUrl = url + path + authHash;
    if (!chrome.tabs?.create) {
      window.open(targetUrl, '_blank');
      return;
    }
    let tab = adjacentTab || currentTab || (await getActiveTab());
    if (tab?.id && chrome.tabs?.get) {
      try { tab = await chrome.tabs.get(tab.id); } catch (_) {}
    }
    const createOptions = { url: targetUrl, active: true };
    if (tab?.index !== undefined) createOptions.index = tab.index + 1;
    if (tab?.windowId !== undefined) createOptions.windowId = tab.windowId;
    await chrome.tabs.create(createOptions);
  }

  function setStudyButtonsDisabled(disabled) {
    [els.btnStudyFlashcards, els.btnStudyQuiz, els.btnStudyMindmap].forEach((button) => {
      if (button) button.disabled = disabled;
    });
  }

  function studySourceText() {
    if (lastSubtitleText.trim()) return lastSubtitleText;
    return subtitleSegments.map((segment) => segment.content || '').join('');
  }

  function studyInputCharCount() {
    return getSummaryInputCharCount(studySourceText());
  }

  function isQinxueStudyInputTooLong(text = studySourceText()) {
    return currentUserTier === 'qinxue' && getSummaryInputCharCount(text) > QINXUE_STUDY_CHAR_LIMIT;
  }

  function updateStudyActionState() {
    const sourceText = studySourceText();
    const chars = getSummaryInputCharCount(sourceText);
    const waitingForVideo = !videoData?.bvid || !currentTab?.id;
    const waitingForTranscript = waitingForVideo || (!sourceText.trim() && (isSubtitleLoading || videoData?.hasSubtitle === false));
    const blockedByLength = isQinxueStudyInputTooLong();
    [
      [els.btnStudyFlashcards, els.studyFlashcardsTooltip, t('studyFlashcards'), t('studyFlashcardsTip')],
      [els.btnStudyQuiz, els.studyQuizTooltip, t('studyQuiz'), t('studyQuizTip')],
      [els.btnStudyMindmap, els.studyMindmapTooltip, t('studyMindmap'), t('studyMindmapTip')],
    ].forEach(([button, tooltip, feature, tip]) => {
      if (!button || !tooltip) return;
      tooltip.textContent = waitingForTranscript
        ? '总结完成并提取字幕后即可使用'
        : blockedByLength
          ? tf('studyQinxueLimit', { feature, chars: chars.toLocaleString('zh-CN') })
          : tip + ' ' + t('studyExperimentFree');
      button.disabled = waitingForTranscript || blockedByLength || studyActionBusy;
      button.classList.toggle('study-action-blocked', blockedByLength);
      button.classList.toggle('subtitle-pending-action', waitingForTranscript);
      button.setAttribute('aria-disabled', waitingForTranscript || blockedByLength ? 'true' : 'false');
      button.setAttribute('aria-describedby', tooltip.id);
    });
  }

  async function ensureCurrentStudySource() {
    if (!videoData || !currentTab?.id) throw new Error('请先打开要学习的 B站视频');
    await refreshCurrentVideoRuntimeState();
    if (!lastSubtitleText.trim()) {
      await loadSubtitleDirect({ maxAttempts: 1, detectionId: videoDetectionId });
    }
    if (lastSubtitleText.trim()) {
      return {
        transcript: lastSubtitleText.trim(),
        sourceType: lastSubtitleWasTranscribedAudio ? 'audio_transcription' : 'subtitle',
      };
    }
    throw new Error('总结完成并提取字幕后即可使用记忆闪卡、知识测验和思维导图');
  }

  async function openStudyPage(mode) {
    if (studyActionBusy || isProcessing || isAudioTranscribing) return;
    if (!videoData?.bvid || !currentTab?.id) {
      updateStudyActionState();
      return;
    }
    if (!studySourceText().trim() && (isSubtitleLoading || videoData?.hasSubtitle === false)) {
      updateStudyActionState();
      return;
    }
    if (currentTab?.id) {
      // 切换到学习页面前先暂停原视频
      chrome.tabs.sendMessage(currentTab.id, { action: 'pauseBilibiliVideo' }).catch(() => {});
    }
    if (isQinxueStudyInputTooLong()) {
      const feature = mode === 'quiz' ? t('studyQuiz') : mode === 'mindmap' ? t('studyMindmap') : t('studyFlashcards');
      showToast(tf('studyQinxueLimit', { feature, chars: studyInputCharCount().toLocaleString('zh-CN') }), 'error', 5000);
      return;
    }
    studyActionBusy = true;
    setStudyButtonsDisabled(true);
    try {
      const sourceTab = currentTab ? { id: currentTab.id, index: currentTab.index, windowId: currentTab.windowId } : await getActiveTab();
      // 历史记录查询也必须使用刚从当前页面确认的 bvid/cid，不能沿用 SPA 切换前的侧栏状态。
      const identityConfirmed = await refreshCurrentVideoRuntimeState();
      if (!identityConfirmed) throw new Error('无法确认当前视频，请刷新 B站页面后重试');
      const session = await requireLoggedInSession();
      const serverBaseUrl = defaultServerBaseUrl(session.serverBaseUrl);
      const bvid = videoData?.bvid || '';
      let existingRecordId = '';
      if (bvid) {
        try {
          const lookupResponse = await fetch(`${serverBaseUrl}/api/study/records/lookup?bvid=${encodeURIComponent(bvid)}&cid=${encodeURIComponent(String(videoData.cid || ''))}`, {
            headers: { Authorization: 'Bearer ' + session.token },
          });
          const lookupData = await lookupResponse.json().catch(() => ({}));
          if (lookupResponse.ok && lookupData.record?.id) existingRecordId = lookupData.record.id;
        } catch (_) { /* 继续走新建流程 */ }
      }
      if (existingRecordId) {
        await openDashboardNextToCurrent(`/study.html?record=${encodeURIComponent(existingRecordId)}&mode=${encodeURIComponent(mode)}`, sourceTab);
        return;
      }
      const source = await ensureCurrentStudySource();
      if (isQinxueStudyInputTooLong(source.transcript)) {
        const feature = mode === 'quiz' ? t('studyQuiz') : mode === 'mindmap' ? t('studyMindmap') : t('studyFlashcards');
        throw new Error(tf('studyQinxueLimit', {
          feature,
          chars: getSummaryInputCharCount(source.transcript).toLocaleString('zh-CN'),
        }));
      }
      const response = await fetch(serverBaseUrl + '/api/study/sessions', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + session.token },
        body: JSON.stringify({
          mode,
          learningMode: 'auto',
          transcript: source.transcript,
          source_type: source.sourceType,
          bvid: videoData.bvid,
          cid: String(videoData.cid || ''),
          title: learningRecordTitle(videoData),
          owner: videoData.owner || '',
          duration_seconds: Number(videoData.duration || 0),
        }),
      });
      const data = await response.json().catch(() => ({}));
      if (!response.ok || !data.id) throw Object.assign(new Error(data.error || '学习内容上传失败'), { code: data.code });
      await openDashboardNextToCurrent('/study.html?session=' + encodeURIComponent(data.id) + '&mode=' + encodeURIComponent(mode), sourceTab);
    } catch (err) {
      const message = normalizeUserFacingError(err.message || '打开学习页面失败', err.code);
      if (isLoginRequiredMessage(message)) showLoginRequiredPrompt(message);
      else showToast(message, 'error', 5000);
    } finally {
      studyActionBusy = false;
      updateStudyActionState();
    }
  }

  async function requireLoggedInSession() {
    const session = await chrome.runtime.sendMessage({ action: 'serverMe' });
    if (!session?.success || !session.token) {
      updateCreditBalanceDisplay(null);
      throw new Error(session?.error || '未登录');
    }
    return session;
  }

  function normalizeServerBaseUrl(url) {
    const value = String(url || '').trim().replace(/\/+$/, '');
    if (/^https?:\/\/(?:localhost|127\.0\.0\.1):3010$/i.test(value)) {
      return DEFAULT_SERVER_BASE_URL;
    }
    return value;
  }

  function defaultServerBaseUrl(url) {
    const value = normalizeServerBaseUrl(url);
    return (!value || isProviderApiUrl(value)) ? DEFAULT_SERVER_BASE_URL : value;
  }

  // ============ 自定义风格 ============
  const STYLE_LIMITS = { qinxue: 2, duxue: 30, mengxue: 30, admin: 30 };
  const BUILTIN_STYLE_TIPS = {
    concise: '被你发现啦，最省时间最省学分的档位',
    standard: '嘿！这是标准档位哦',
    detailed: '此档位总结字数会更多，消耗学分可能会超过预估一些哟~',
  };

  function getCurrentMonthKey() {
    const now = new Date();
    return now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
  }

  function getNextMonthStartDate() {
    const now = new Date();
    const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
    return next.getMonth() + 1 + '月' + next.getDate() + '日';
  }

  function getStyleAddLimit() {
    return STYLE_LIMITS[currentUserTier] || STYLE_LIMITS.qinxue;
  }

  async function getStyleAdditionsCount() {
    const key = accountStorageKey('styleAdditions');
    if (!key) return { month: getCurrentMonthKey(), count: 0 };
    const r = await chrome.storage.local.get({ [key]: { month: '', count: 0 } });
    const saved = r[key];
    const currentMonth = getCurrentMonthKey();
    if (saved.month !== currentMonth) {
      return { month: currentMonth, count: 0 };
    }
    return saved;
  }

  async function incrementStyleAdditions() {
    const data = await getStyleAdditionsCount();
    data.count += 1;
    const key = accountStorageKey('styleAdditions');
    if (key) await chrome.storage.local.set({ [key]: data });
  }

  async function updateStyleInputTooltip() {
    const data = await getStyleAdditionsCount();
    const limit = getStyleAddLimit();
    const remaining = Math.max(0, limit - data.count);
    const tierLabel = { qinxue: '勤学会员', duxue: '笃学会员', mengxue: '博学会员' }[currentUserTier] || '勤学会员';
    const tooltipEl = document.getElementById('custom-prompt-tooltip');
    if (tooltipEl) {
      if (remaining > 0) {
        tooltipEl.textContent = tf('styleRemaining', { tier: tierLabel, count: remaining });
      } else {
        tooltipEl.textContent = currentLanguage === 'en' ? 'Available again ' + getNextMonthStartDate() : (currentLanguage === 'zh-TW' ? '目前會員可於 ' + getNextMonthStartDate() + ' 再次定義' : '当前会员' + getNextMonthStartDate() + '可再次定义');
      }
    }
  }

  async function addCustomStyle() {
    const text = els.customPromptInput.value.trim();
    if (!text) return;
    if (text.length > CUSTOM_STYLE_MAX_LENGTH) {
      showToast(t('customStyleTooLong'), 'error');
      els.customPromptInput.focus();
      return;
    }
    // 检查风格总数上限
    const totalStyles = els.styleSelect.options.length;
    if (totalStyles >= STYLE_MAX_COUNT) {
      showToast('风格过多，请先删除闲置风格', 'error');
      return;
    }
    // 检查本月添加次数限制
    const data = await getStyleAdditionsCount();
    const limit = getStyleAddLimit();
    if (data.count >= limit) {
      const tierLabel = { qinxue: '勤学会员', duxue: '笃学会员', mengxue: '博学会员' }[currentUserTier] || '勤学会员';
      showToast(tf('styleLimit', { tier: tierLabel, date: getNextMonthStartDate() }), 'error');
      return;
    }
    const v = 'custom_' + Date.now();
    addStyleOption(v, text, true);
    els.customPromptInput.value = '';
    await incrementStyleAdditions();
    updateStyleControls();
    saveSelectedStyle();
    renderCustomStyleList();
  }

  // 辅助生成：把用户“看视频学到的东西”交给 AI 提炼成一句风格提示词，
  // 填入直接添加框并高亮，可编辑后再点击添加。该生成按 token 消耗正常扣学分。
  async function assistStyleGeneration() {
    if (els.btnAssistStyle.disabled) return;
    const text = els.assistPromptInput.value.trim();
    if (!text) {
      showToast(t('assistStyleEmpty'), 'error');
      els.assistPromptInput.focus();
      return;
    }
    if (text.length > 500) {
      showToast(t('assistStyleTooLong'), 'error');
      els.assistPromptInput.focus();
      return;
    }
    const originalLabel = els.btnAssistStyle.textContent;
    els.btnAssistStyle.disabled = true;
    els.btnAssistStyle.textContent = t('assistStyleGenerating');
    try {
      const r = await chrome.runtime.sendMessage({ action: 'assistStyle', text, language: currentLanguage });
      if (!r?.success) throwResponseError(r, '风格提示词生成失败');
      const prompt = String(r.prompt || '').slice(0, CUSTOM_STYLE_MAX_LENGTH);
      els.customPromptInput.value = prompt;
      els.customPromptInput.classList.remove('style-assist-highlight');
      void els.customPromptInput.offsetWidth; // 强制重排以重启动画
      els.customPromptInput.classList.add('style-assist-highlight');
      els.customPromptInput.focus();
      els.customPromptInput.setSelectionRange(prompt.length, prompt.length);
      showToast(t('assistStyleSuccess'), 'success');
    } catch (err) {
      showToast(normalizeUserFacingError(err.message || '风格提示词生成失败', err.code), 'error');
    } finally {
      els.btnAssistStyle.disabled = false;
      els.btnAssistStyle.textContent = originalLabel;
    }
  }

  function addStyleOption(value, label, save) {
    if ([...els.styleSelect.options].find((o) => o.text === '✏️ ' + label || o.title === label)) {
      els.styleSelect.value = [...els.styleSelect.options].find((o) => o.title === label)?.value || value;
      return;
    }
    const opt = document.createElement('option');
    opt.value = value;
    opt.textContent = '✏️ ' + (label.length > 12 ? label.slice(0, 12) + '…' : label);
    opt.title = label;
    els.styleSelect.appendChild(opt);
    els.styleSelect.value = value;
    if (save) saveCustomStyles();
    updateStyleControls();
  }

  function deleteCustomStyle(value) {
    const opt = [...els.styleSelect.options].find((option) => option.value === value && option.value.startsWith('custom_'));
    if (!opt) return;
    const wasSelected = els.styleSelect.value === value;
    opt.remove();
    if (wasSelected) els.styleSelect.value = 'standard';
    saveCustomStyles();
    saveSelectedStyle();
    updateStyleControls();
    renderCustomStyleList();
    showToast('已删除自定义风格', 'success');
  }

  function updateStyleControls() {
    const totalStyles = els.styleSelect.options.length;
    els.btnAddStyle.disabled = totalStyles >= STYLE_MAX_COUNT;
    els.btnAddStyle.title = totalStyles >= STYLE_MAX_COUNT ? (currentLanguage === 'en' ? 'Too many styles; delete an unused one first' : (currentLanguage === 'zh-TW' ? '風格過多，請先刪除未使用的風格' : '风格过多，请先删除闲置风格')) : t('addCustomStyle');
    updateStyleInputTooltip();
  }

  function openStyleManager() {
    els.settingsPanel.classList.add('hidden');
    els.collectionPanel.classList.add('hidden');
    els.toolboxPanel.classList.add('hidden');
    els.historyPanel.classList.add('hidden');
    els.mobileSummaryPanel.classList.add('hidden');
    // 上次辅助生成的闪烁高亮只在生成后生效一次，重开面板时清掉
    els.customPromptInput.classList.remove('style-assist-highlight');
    renderCustomStyleList();
    els.styleManagerPanel.classList.remove('hidden');
    els.assistPromptInput.focus();
  }

  function renderCustomStyleList() {
    if (!els.customStyleList) return;
    const options = [...els.styleSelect.options].filter((option) => option.value.startsWith('custom_'));
    els.customStyleList.textContent = '';
    if (!options.length) {
      const empty = document.createElement('div');
      empty.className = 'custom-style-empty';
      empty.textContent = '暂无自定义风格';
      els.customStyleList.appendChild(empty);
      return;
    }
    options.forEach((option) => {
      const row = document.createElement('div');
      row.className = 'custom-style-item';
      const label = document.createElement('span');
      label.textContent = option.title || option.textContent.replace(/^✏️\s*/, '');
      const button = document.createElement('button');
      button.type = 'button';
      button.className = 'icon-btn custom-style-delete';
      button.title = '删除自定义风格';
      button.setAttribute('aria-label', '删除自定义风格');
      button.textContent = '×';
      button.addEventListener('click', () => deleteCustomStyle(option.value));
      row.append(label, button);
      els.customStyleList.appendChild(row);
    });
  }

  function saveCustomStyles() {
    const styles = [...els.styleSelect.options]
      .filter((o) => o.value.startsWith('custom_'))
      .map((o) => ({ value: o.value, label: o.title || o.textContent }));
    const key = accountStorageKey('customStyles');
    if (key) chrome.storage.local.set({ [key]: styles });
  }

  function saveSelectedStyle() {
    const key = accountStorageKey('selectedStyle');
    if (key) chrome.storage.local.set({ [key]: els.styleSelect.value || 'standard' });
  }

  async function clearLegacySubtitleCache() {
    if (clearedLegacySubtitleCache) return;
    clearedLegacySubtitleCache = true;
    try {
      const all = await chrome.storage.local.get(null);
      const keys = Object.keys(all).filter((key) => key.startsWith('sub_'));
      if (keys.length) await chrome.storage.local.remove(keys);
    } catch (e) {
      console.warn('[BiliSummary] 清理旧字幕缓存失败:', e.message);
    }
  }

  async function getActiveBilibiliTab() {
    const supportedUrls = [
      'https://www.bilibili.com/video/*',
      'https://www.bilibili.com/bangumi/play/*',
      'https://www.bilibili.com/cheese/play/*',
      'https://www.bilibili.com/list/watchlater/*',
    ];
    const queryOptions = [
      { active: true, lastFocusedWindow: true, url: supportedUrls },
      { active: true, currentWindow: true, url: supportedUrls },
    ];
    for (const options of queryOptions) {
      const [tab] = await chrome.tabs.query(options);
      if (tab) return tab;
    }

    const [activeTab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
    if (activeTab && !/^https:\/\/www\.bilibili\.com\/(?:video\/|bangumi\/play\/|cheese\/play\/|list\/watchlater\/)/.test(activeTab.url || '')) {
      return null;
    }
    return activeTab || null;
  }

  async function getActiveTab() {
    const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
    return tab || null;
  }

  function videoTabKey(tab) {
    try {
      const url = new URL(tab?.url || '');
      const bvid = url.pathname.match(/^\/video\/(BV[\w]+)/i)?.[1]?.toUpperCase()
        || (/^\/list\/watchlater\//i.test(url.pathname) ? (url.searchParams.get('bvid') || '') : '').toUpperCase()
        || '';
      if (bvid) {
        const page = Math.max(1, Number.parseInt(url.searchParams.get('p') || '1', 10) || 1);
        return `${tab.id}:${bvid}:${page}`;
      }
      const cheese = url.pathname.match(/^\/cheese\/play\/(ss|ep)(\d+)/i);
      if (cheese) return `${tab.id}:CHEESE:${cheese[1].toLowerCase()}${cheese[2]}`;
      const bangumi = url.pathname.match(/^\/bangumi\/play\/(ss|ep)(\d+)/i);
      return bangumi ? `${tab.id}:BANGUMI:${bangumi[1].toLowerCase()}${bangumi[2]}` : '';
    } catch (_) {
      return '';
    }
  }

  function videoDataMatchesTab(data, tab) {
    const key = videoTabKey(tab);
    if (!key || !data?.bvid) return false;
    if (key.includes(':CHEESE:')) {
      const routeId = key.split(':')[2];
      return routeId === `ep${data.epId}` || routeId === `ss${data.seasonId}`;
    }
    if (key.includes(':BANGUMI:')) {
      const routeId = key.split(':')[2];
      return routeId === `ep${data.epId}` || routeId === `ss${data.seasonId}`;
    }
    const [, bvid, page] = key.split(':');
    return String(data.bvid).toUpperCase() === bvid && String(data.page || 1) === page;
  }

  function createResultMetadata(data = videoData, tab = currentTab) {
    if (!data) return null;
    const bvid = String(data.bvid || '');
    const canonicalUrl = data?.pageType === 'cheese' && data?.epId
      ? `https://www.bilibili.com/cheese/play/ep${data.epId}`
      : (data?.pageType === 'bangumi' && data?.epId
        ? `https://www.bilibili.com/bangumi/play/ep${data.epId}`
        : (bvid ? `https://www.bilibili.com/video/${bvid}` : ''));
    const tabUrl = String(tab?.url || '');
    const url = data?.pageType === 'cheese'
      ? (tabUrl.includes('/cheese/play/') ? tabUrl : canonicalUrl)
      : (data?.pageType === 'bangumi'
        ? (tabUrl.includes('/bangumi/play/') ? tabUrl : canonicalUrl)
        : (!bvid || tabUrl.includes('/video/' + bvid) ? (tabUrl || canonicalUrl) : canonicalUrl));
    return Object.freeze({
      title: String(data.title || ''),
      bvid,
      cid: String(data.cid || ''),
      url,
    });
  }

  function learningRecordTitle(data = videoData) {
    return Array.isArray(data?.pages) && data.pages.length > 1 && data.part
      ? data.part
      : (data?.title || '');
  }

  function sanitizeCollectionFilename(name) {
    const sanitized = String(name || '')
      .replace(/[\\/:*?"<>|]/g, '_')
      .replace(/[. ]+$/g, '')
      .trim()
      .substring(0, 80);
    if (!sanitized) return '未命名视频';
    return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(sanitized) ? '_' + sanitized : sanitized;
  }

  async function collectionFilenameExists(directoryHandle, filename) {
    try {
      await directoryHandle.getFileHandle(filename);
      return true;
    } catch (err) {
      if (err?.name === 'NotFoundError') return false;
      throw err;
    }
  }

  function collectionTextExtension() {
    return saveTextsAsMarkdown ? 'md' : 'txt';
  }

  async function getAvailableCollectionFilename(directoryHandle, title, extension) {
    const base = sanitizeCollectionFilename(title);
    const suffix = String(extension || collectionTextExtension()).replace(/^\./, '').toLowerCase();
    for (let index = 1; index <= 9999; index += 1) {
      const filename = (index === 1 ? base : base + ' (' + index + ')') + '.' + suffix;
      if (!(await collectionFilenameExists(directoryHandle, filename))) return filename;
    }
    throw new Error('Collection folder has too many files with the same name');
  }

  async function writeCollectionFile(directoryHandle, resultMeta, content) {
    const filename = await getAvailableCollectionFilename(directoryHandle, resultMeta.title, collectionTextExtension());
    const fileHandle = await directoryHandle.getFileHandle(filename, { create: true });
    const writable = await fileHandle.createWritable();
    try {
      await writable.write('视频链接: ' + resultMeta.url + '\n\n' + content);
      await writable.close();
    } catch (err) {
      try { await writable.abort(); } catch (abortError) {}
      throw err;
    }
    return filename;
  }

  async function getAvailableToolboxFilename(directoryHandle, title, extension) {
    const base = sanitizeCollectionFilename(title);
    const suffix = String(extension || '').replace(/^\./, '').toLowerCase();
    for (let index = 1; index <= 9999; index += 1) {
      const filename = (index === 1 ? base : `${base} (${index})`) + (suffix ? `.${suffix}` : '');
      if (!(await collectionFilenameExists(directoryHandle, filename))) return filename;
    }
    throw new Error('收藏夹中同名文件过多');
  }

  async function requireToolboxDirectory() {
    if (!collectionDirectoryHandle) {
      const handle = await selectCollectionDirectory();
      if (!handle) throw new DOMException('已取消选择文件夹', 'AbortError');
    }
    if (!(await ensureCollectionDirectoryPermission(true))) throw new DOMException('收藏夹未授权', 'NotAllowedError');
    return collectionDirectoryHandle;
  }

  async function writeToolboxBlob(directory, title, extension, blob) {
    const filename = await getAvailableToolboxFilename(directory, title, extension);
    const handle = await directory.getFileHandle(filename, { create: true });
    const writable = await handle.createWritable();
    try {
      await writable.write(blob);
      await writable.close();
    } catch (err) {
      try { await writable.abort(); } catch {}
      try { await directory.removeEntry(filename); } catch {}
      throw err;
    }
    return filename;
  }

  async function runToolboxAction(button, action) {
    if (toolboxBusy) return;
    toolboxBusy = true;
    const originalText = button.textContent;
    const actionButtons = els.toolboxPanel.querySelectorAll('button');
    actionButtons.forEach((item) => { item.disabled = true; });
    button.textContent = '处理中...';
    try {
      await action();
    } catch (err) {
      if (err?.name !== 'AbortError') showToast(err?.message || '操作失败', 'error', 5000);
    } finally {
      toolboxBusy = false;
      actionButtons.forEach((item) => { item.disabled = toolboxContextLoading; });
      els.toolboxAudioFormat.disabled = toolboxContextLoading;
      els.toolboxVideoQuality.disabled = toolboxContextLoading;
      button.textContent = originalText;
    }
  }

  async function fetchBilibiliPageJson(url) {
    return fetchBilibiliPageJsonForTab(currentTab?.id, url);
  }

  async function fetchBilibiliPageJsonForTab(tabId, url) {
    if (!tabId) throw new Error('请先打开 B站视频页');
    const response = await chrome.runtime.sendMessage({ action: 'fetchJsonInPage', tabId, url });
    if (!response?.success) throw new Error(response?.error || '读取 B站数据失败');
    if (response.data?.code !== 0) throw new Error(response.data?.message || `B站接口错误 ${response.data?.code}`);
    return response.data.data;
  }

  async function openToolbox() {
    els.settingsPanel.classList.add('hidden');
    els.collectionPanel.classList.add('hidden');
    els.historyPanel.classList.add('hidden');
    els.styleManagerPanel.classList.add('hidden');
    els.mobileSummaryPanel.classList.add('hidden');
    els.toolboxPanel.classList.remove('hidden');
    refreshToolboxVideoContext();
    await refreshCollectionDirectoryPermission();
    void refreshToolboxMetadata();
    void renderBilibiliFavorites().catch((err) => setToolboxFavoritesState(err.message || '读取收藏夹失败'));
  }

  function refreshToolboxVideoContext({ loading = false } = {}) {
    toolboxContextLoading = loading;
    const hasVideo = !!videoData && !!currentTab?.id;
    const hasCollection = toolboxCollectionEpisodes.length > 1;
    els.btnDownloadAllAudio.classList.toggle('hidden', !hasCollection);
    els.btnDownloadAllVideo.classList.toggle('hidden', !hasCollection);
    [els.btnDownloadCover, els.btnDownloadComments, els.btnDownloadAudio, els.btnDownloadAllAudio]
      .forEach((button) => { button.disabled = loading || !hasVideo || toolboxBusy; });
    const videoAvailable = toolboxQualityBvid === videoData?.bvid && toolboxAvailableQualities.length > 0;
    [els.btnDownloadVideo, els.btnDownloadAllVideo]
      .forEach((button) => { button.disabled = loading || !hasVideo || !videoAvailable || toolboxBusy; });
    els.toolboxAudioFormat.disabled = loading || !hasVideo || toolboxBusy;
    els.toolboxVideoQuality.disabled = loading || !hasVideo || !videoAvailable || toolboxBusy;
  }

  function extractToolboxCollectionEpisodes(details) {
    const episodes = [];
    const seen = new Set();
    const sections = Array.isArray(details?.ugc_season?.sections) ? details.ugc_season.sections : [];
    for (const section of sections) {
      for (const episode of Array.isArray(section?.episodes) ? section.episodes : []) {
        const bvid = String(episode?.bvid || '');
        const cid = String(episode?.cid || '');
        if (!bvid || !cid || seen.has(bvid + ':' + cid)) continue;
        seen.add(bvid + ':' + cid);
        episodes.push({
          bvid,
          cid,
          page: Number(episode?.page || 1),
          part: String(episode?.title || ''),
        });
      }
    }
    return episodes;
  }

  async function refreshToolboxMetadata() {
    if (!videoData?.bvid || !currentTab?.id) return;
    const bvid = String(videoData.bvid);
    const tabId = currentTab.id;
    toolboxQualityBvid = '';
    toolboxAvailableQualities = [];
    toolboxCollectionTitle = '';
    toolboxCollectionEpisodes = [];
    refreshToolboxVideoContext({ loading: true });
    try {
      const details = await fetchBilibiliPageJsonForTab(tabId, `https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`);
      if (videoData?.bvid !== bvid || currentTab?.id !== tabId) return;
      if (Array.isArray(details?.pages) && details.pages.length) videoData.pages = details.pages;
      toolboxCollectionTitle = String(details?.ugc_season?.title || '');
      toolboxCollectionEpisodes = extractToolboxCollectionEpisodes(details);
      refreshToolboxVideoContext();
      const currentPage = videoData.pages?.find((page) => String(page.cid) === String(videoData.cid));
      const cid = String(currentPage?.cid || videoData.cid || '');
      const response = await chrome.runtime.sendMessage({ action: 'getBilibiliToolboxData', tabId, bvid, cid, quality: 80 });
      if (!response?.success || videoData?.bvid !== bvid || currentTab?.id !== tabId) return;
      const qualities = Array.isArray(response.data?.availableQualities) ? response.data.availableQualities : [];
      const labels = { 80: '1080p', 64: '720p', 32: '480p', 16: '360p' };
      const previous = Number(els.toolboxVideoQuality.value || 80);
      toolboxQualityBvid = bvid;
      toolboxAvailableQualities = qualities;
      if (!qualities.length) {
        const option = document.createElement('option');
        option.value = '';
        option.textContent = '暂无视频流';
        els.toolboxVideoQuality.replaceChildren(option);
        return;
      }
      els.toolboxVideoQuality.replaceChildren(...qualities.map((quality) => {
        const option = document.createElement('option');
        option.value = String(quality);
        option.textContent = labels[quality] || `${quality}P`;
        return option;
      }));
      if (qualities.includes(previous)) els.toolboxVideoQuality.value = String(previous);
    } catch (_) {
      if (videoData?.bvid === bvid && currentTab?.id === tabId) {
        toolboxQualityBvid = bvid;
        toolboxAvailableQualities = [];
      }
    } finally {
      if (videoData?.bvid === bvid && currentTab?.id === tabId) refreshToolboxVideoContext();
    }
  }

  async function downloadVideoCover() {
    const context = createToolboxContext();
    if (!context.pic) throw new Error('当前视频没有可下载的封面');
    const directory = await requireToolboxDirectory();
    const coverUrl = context.pic.replace(/^http:/i, 'https:');
    const response = await fetch(coverUrl, { cache: 'no-store', credentials: 'omit' });
    if (!response.ok) throw new Error(`封面下载失败 HTTP ${response.status}`);
    const contentType = response.headers.get('content-type') || '';
    const extension = contentType.includes('png') ? 'png' : contentType.includes('webp') ? 'webp' : 'jpg';
    const filename = await writeToolboxBlob(directory, context.title, extension, await response.blob());
    showToast(`封面已保存：${filename}`, 'success', 4000);
  }

  function flattenVideoComments(replies, output, depth = 0) {
    for (const reply of Array.isArray(replies) ? replies : []) {
      output.push({
        root: reply.root || reply.rpid,
        rpid: reply.rpid,
        depth,
        name: reply.member?.uname || '未知用户',
        time: Number(reply.ctime || 0),
        likes: Number(reply.like || 0),
        message: String(reply.content?.message || '').trim(),
      });
      flattenVideoComments(reply.replies, output, depth + 1);
    }
  }

  function createToolboxContext() {
    if (!videoData || !currentTab?.id) throw new Error('请先打开 B站视频页');
    return Object.freeze({
      tabId: currentTab.id,
      bvid: String(videoData.bvid || ''),
      aid: Number(videoData.aid || 0),
      cid: String(videoData.cid || ''),
      page: Number(videoData.page || 1),
      part: String(videoData.part || ''),
      title: String(videoData.title || ''),
      pic: String(videoData.pic || ''),
      pages: (Array.isArray(videoData.pages) ? videoData.pages : []).map((page) => ({
        cid: String(page.cid || ''), page: Number(page.page || 0), part: String(page.part || ''),
      })),
    });
  }

  async function fetchAllVideoComments(context) {
    const oid = context.aid;
    if (!oid) throw new Error('当前视频缺少评论区 ID');
    const comments = [];
    const seen = new Set();
    let page = 1;
    let loadedRoots = 0;
    let expectedRoots = Infinity;
    while (loadedRoots < expectedRoots && page <= 1000) {
      const url = `https://api.bilibili.com/x/v2/reply?type=1&oid=${oid}&pn=${page}&ps=20&sort=0`;
      const data = await fetchBilibiliPageJsonForTab(context.tabId, url);
      expectedRoots = Number(data?.page?.count || 0);
      const roots = Array.isArray(data?.replies) ? data.replies : [];
      if (!roots.length) break;
      loadedRoots += roots.length;
      for (const root of roots) {
        const rootComments = [];
        flattenVideoComments([root], rootComments);
        const childCount = Number(root.rcount || 0);
        if (childCount > (root.replies?.length || 0)) {
          for (let childPage = 1, childLoaded = 0; childLoaded < childCount && childPage <= 1000; childPage += 1) {
            const childUrl = `https://api.bilibili.com/x/v2/reply/reply?type=1&oid=${oid}&root=${root.rpid}&pn=${childPage}&ps=20`;
            const children = await fetchBilibiliPageJsonForTab(context.tabId, childUrl);
            const childReplies = Array.isArray(children?.replies) ? children.replies : [];
            if (!childReplies.length) break;
            childLoaded += childReplies.length;
            flattenVideoComments(childReplies, rootComments, 1);
          }
        }
        for (const comment of rootComments) {
          if (!comment.rpid || seen.has(comment.rpid)) continue;
          seen.add(comment.rpid);
          comments.push(comment);
        }
      }
      page += 1;
    }
    return comments;
  }

  function formatVideoComments(context, comments) {
    const lines = [`视频：${context.title}`, `链接：https://www.bilibili.com/video/${context.bvid}`, `评论数：${comments.length}`, ''];
    const grouped = new Map();
    comments.forEach((comment) => {
      const root = String(comment.root || comment.rpid);
      if (!grouped.has(root)) grouped.set(root, []);
      grouped.get(root).push(comment);
    });
    let index = 0;
    grouped.forEach((thread) => {
      lines.push('='.repeat(48));
      thread.forEach((comment) => {
        index += 1;
        const date = comment.time ? new Date(comment.time * 1000).toLocaleString('zh-CN') : '未知时间';
        const indent = comment.depth ? '  '.repeat(comment.depth) : '';
        lines.push(`${indent}${index}. ${comment.depth ? '回复' : '评论'}者：${comment.name} | ${date} | ${comment.likes}赞`);
        lines.push(`${indent}${comment.message || '[无文字内容]'}`);
      });
      lines.push('');
    });
    return lines.join('\n').trim() + '\n';
  }

  async function downloadVideoComments() {
    if (!canUseToolboxPremiumDownload()) throw new Error('当前仅笃学会员、博学会员支持下载评论');
    const context = createToolboxContext();
    const directory = await requireToolboxDirectory();
    const comments = await fetchAllVideoComments(context);
    const commentText = formatVideoComments(context, comments);
    const filename = await writeToolboxBlob(directory, toolboxCommentTitle(context.title), collectionTextExtension(), commentText);
    showToast(`已保存 ${comments.length} 条评论：${filename}`, 'success', 5000);
  }

  function mediaExtension(media, kind) {
    const mime = String(media?.mimeType || '').toLowerCase();
    if (mime.includes('flv')) return 'flv';
    if (mime.includes('webm')) return 'webm';
    if (kind === 'audio') return mime.includes('mpeg') ? 'mp3' : 'm4a';
    return 'mp4';
  }

  function toolboxCommentTitle(title) {
    const suffix = '】视频评论';
    return `【${sanitizeCollectionFilename(title).slice(0, 80 - suffix.length - 1)}${suffix}`;
  }

  function toolboxMediaTitle(context, page) {
    if (page.bvid || context.collection) {
      const prefix = `P${String(page.page || 1).padStart(2, '0')}-`;
      const part = sanitizeCollectionFilename(page.part || '合集').slice(0, 24);
      const reserved = prefix.length + part.length + 1;
      return `${prefix}${sanitizeCollectionFilename(context.collectionTitle || context.title).slice(0, Math.max(20, 80 - reserved))}-${part}`;
    }
    if (context.pages.length <= 1) return context.title;
    const prefix = `P${String(page.page || 1).padStart(2, '0')}-`;
    const part = sanitizeCollectionFilename(page.part || '分P').slice(0, 24);
    const reserved = prefix.length + part.length + 1;
    return `${prefix}${sanitizeCollectionFilename(context.title).slice(0, Math.max(20, 80 - reserved))}-${part}`;
  }

  function decodeBase64Chunk(value) {
    const binary = atob(value);
    const bytes = new Uint8Array(binary.length);
    for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
    return bytes;
  }

  function encodeBase64Chunk(bytes) {
    let binary = '';
    for (let offset = 0; offset < bytes.length; offset += 0x8000) {
      binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
    }
    return btoa(binary);
  }

  async function writePreparedKimiAudio(directory, title, prepared, onProgress) {
    const preparationId = String(prepared?.preparationId || '');
    const total = Math.max(0, Number(prepared?.size || 0));
    if (!/^[a-f0-9]{48}$/i.test(preparationId) || !total) throw new Error('Kimi 压缩音频结果无效');
    const filename = await getAvailableToolboxFilename(directory, `【kimi用加速压缩版】${sanitizeCollectionFilename(title)}`, 'mp3');
    const fileHandle = await directory.getFileHandle(filename, { create: true });
    const writable = await fileHandle.createWritable();
    const chunkSize = 512 * 1024;
    try {
      let offset = 0;
      while (offset < total) {
        const response = await chrome.runtime.sendMessage({
          action: 'fetchPreparedKimiAudioChunk', preparationId,
          start: offset, end: Math.min(total - 1, offset + chunkSize - 1), total,
        });
        if (!response?.success) throw new Error(response?.error || '读取 Kimi 压缩音频失败');
        const bytes = decodeBase64Chunk(response.data?.base64 || '');
        if (!bytes.byteLength) throw new Error('读取 Kimi 压缩音频中断');
        await writable.write({ type: 'write', position: offset, data: bytes });
        offset += bytes.byteLength;
        onProgress?.(offset, total);
      }
      await writable.close();
      return filename;
    } catch (err) {
      try { await writable.abort(); } catch {}
      try { await directory.removeEntry(filename); } catch {}
      throw err;
    } finally {
      void chrome.runtime.sendMessage({ action: 'deletePreparedKimiAudio', preparationId }).catch(() => {});
    }
  }

  async function writeBilibiliMedia(directory, context, page, kind, quality, mediaData, onProgress, audioFormat = '', onStatus) {
    const media = mediaData?.[kind];
    const segments = kind === 'video' ? media?.segments : [{ urls: media?.urls || [] }];
    if (!segments?.length || segments.some((segment) => !segment.urls?.length)) throw new Error(`P${page.page || 1} 暂无可下载的${kind === 'audio' ? '音频' : '视频'}流`);
    if (kind === 'video' && Number(mediaData.quality) !== quality) {
      const labels = { 80: '1080p', 64: '720p', 32: '480p', 16: '360p' };
      throw new Error(`${labels[quality]} 不可用，B站仅返回 ${labels[mediaData.quality] || mediaData.quality + 'P'}`);
    }
    const itemBvid = page.bvid || context.bvid;
    const title = toolboxMediaTitle(context, page);
    const chunkSize = 1024 * 1024;
    const mp3Target = kind === 'audio' && audioFormat === 'mp3' && !String(media?.mimeType || '').toLowerCase().includes('mpeg');
    const filenames = [];
    for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) {
      const segment = segments[segmentIndex];
      const segmentTitle = segments.length > 1 ? `${title}-片段${String(segmentIndex + 1).padStart(2, '0')}` : title;
      const filename = await getAvailableToolboxFilename(directory, segmentTitle, mp3Target ? 'mp3' : mediaExtension(media, kind));
      const fileHandle = await directory.getFileHandle(filename, { create: true });
      const writable = await fileHandle.createWritable();
      try {
        if (mp3Target) {
          const { chunks, total } = await downloadBilibiliSegmentBytes(segment, context, itemBvid, chunkSize,
            (loaded, all) => onProgress?.(segmentIndex, segments.length, loaded, all));
          const mp3Bytes = await transcodeAudioToMp3(chunks, total, (message, percent) => onStatus?.(message, percent));
          await writable.write(mp3Bytes);
          await writable.close();
          filenames.push(filename);
          continue;
        }
        let segmentOffset = 0;
        let total = 0;
        let selectedUrl = '';
        for (const candidate of segment.urls) {
          const first = await chrome.runtime.sendMessage({
            action: 'fetchBilibiliMediaChunk', tabId: context.tabId, bvid: itemBvid, url: candidate, start: 0, end: chunkSize - 1,
          });
          if (!first?.success || !first.data?.total) continue;
          selectedUrl = candidate;
          total = Number(first.data.total);
          const bytes = decodeBase64Chunk(first.data.base64);
          await writable.write({ type: 'write', position: 0, data: bytes });
          segmentOffset = bytes.byteLength;
          onProgress?.(segmentIndex, segments.length, segmentOffset, total);
          break;
        }
        if (!selectedUrl || !total) throw new Error('B站媒体地址已失效，请刷新视频页后重试');
        while (segmentOffset < total) {
          const chunk = await chrome.runtime.sendMessage({
            action: 'fetchBilibiliMediaChunk', tabId: context.tabId, bvid: itemBvid, url: selectedUrl,
            start: segmentOffset, end: Math.min(total - 1, segmentOffset + chunkSize - 1),
          });
          if (!chunk?.success) throw new Error(chunk?.error || '媒体下载中断');
          const bytes = decodeBase64Chunk(chunk.data.base64);
          if (!bytes.byteLength) throw new Error('媒体下载中断');
          await writable.write({ type: 'write', position: segmentOffset, data: bytes });
          segmentOffset += bytes.byteLength;
          onProgress?.(segmentIndex, segments.length, segmentOffset, total);
        }
        await writable.close();
        filenames.push(filename);
      } catch (err) {
        try { await writable.abort(); } catch {}
        try { await directory.removeEntry(filename); } catch {}
        throw err;
      }
    }
    return filenames;
  }

  async function downloadBilibiliSegmentBytes(segment, context, itemBvid, chunkSize, onProgress) {
    const chunks = [];
    let total = 0;
    let selectedUrl = '';
    let offset = 0;
    for (const candidate of segment.urls) {
      const first = await chrome.runtime.sendMessage({
        action: 'fetchBilibiliMediaChunk', tabId: context.tabId, bvid: itemBvid,
        cid: context.cid, epId: context.epId, url: candidate, start: 0, end: chunkSize - 1,
      });
      if (!first?.success || !first.data?.total) continue;
      selectedUrl = candidate;
      total = Number(first.data.total);
      const bytes = decodeBase64Chunk(first.data.base64);
      chunks.push(bytes);
      offset = bytes.byteLength;
      onProgress?.(offset, total);
      break;
    }
    if (!selectedUrl || !total) throw new Error('B站媒体地址已失效，请刷新视频页后重试');
    while (offset < total) {
      const chunk = await chrome.runtime.sendMessage({
        action: 'fetchBilibiliMediaChunk', tabId: context.tabId, bvid: itemBvid,
        cid: context.cid, epId: context.epId, url: selectedUrl,
        start: offset, end: Math.min(total - 1, offset + chunkSize - 1),
      });
      if (!chunk?.success) throw new Error(chunk?.error || '媒体下载中断');
      const bytes = decodeBase64Chunk(chunk.data.base64);
      if (!bytes.byteLength) throw new Error('媒体下载中断');
      chunks.push(bytes);
      offset += bytes.byteLength;
      onProgress?.(offset, total);
    }
    return { chunks, total };
  }

  function mergeByteArrays(parts) {
    const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
    const merged = new Uint8Array(total);
    let offset = 0;
    for (const part of parts) { merged.set(part, offset); offset += part.byteLength; }
    return merged;
  }

  function mp4WalkBoxes(view, start, end, onBox) {
    let offset = start;
    while (offset + 8 <= end) {
      let size = view.getUint32(offset);
      const type = String.fromCharCode(view.getUint8(offset + 4), view.getUint8(offset + 5), view.getUint8(offset + 6), view.getUint8(offset + 7));
      let headerSize = 8;
      if (size === 1) {
        size = Number(view.getBigUint64(offset + 8));
        headerSize = 16;
      } else if (size === 0) {
        size = end - offset;
      }
      if (size < headerSize || offset + size > end) return;
      onBox?.(type, offset, headerSize, size);
      offset += size;
    }
  }

  function mp4EsdsDescription(view, start, end) {
    const readDescriptor = (pos) => {
      if (pos + 2 > end) return null;
      const tag = view.getUint8(pos);
      pos += 1;
      let size = 0;
      let byte = 0;
      do {
        byte = view.getUint8(pos);
        size = size * 128 + (byte & 0x7f);
        pos += 1;
      } while ((byte & 0x80) && pos < end);
      if (pos + size > end) return null;
      return { tag, start: pos, size, next: pos + size };
    };
    const es = readDescriptor(start);
    if (!es || es.tag !== 0x03) return null;
    let pos = es.start + 3;
    const flags = view.getUint8(es.start + 2);
    if (flags & 0x80) pos += 2;
    if (flags & 0x40) {
      let urlLength = 0;
      let urlByte = 0;
      do {
        urlByte = view.getUint8(pos);
        urlLength = urlLength * 128 + (urlByte & 0x7f);
        pos += 1;
      } while ((urlByte & 0x80) && pos < es.next);
      pos += urlLength;
    }
    if (flags & 0x20) pos += 2;
    const dec = readDescriptor(pos);
    if (!dec || dec.tag !== 0x04) return null;
    const dsi = readDescriptor(dec.start + 13);
    if (!dsi || dsi.tag !== 0x05) return null;
    return new Uint8Array(view.buffer, view.byteOffset + dsi.start, dsi.size).slice();
  }

  function parseFragmentedMp4Audio(bytes) {
    const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
    let audioTrackId = 0;
    let timescale = 0;
    let sampleRate = 0;
    let numberOfChannels = 0;
    let description = null;
    let defaultSampleDuration = 0;
    const truns = [];
    mp4WalkBoxes(view, 0, view.byteLength, (type, offset, headerSize, size) => {
      if (type === 'moov') {
        mp4WalkBoxes(view, offset + headerSize, offset + size, (childType, childOffset, childHeader, childSize) => {
          if (childType === 'trak') {
            let trackId = 0;
            let isAudio = false;
            let trakTimescale = 0;
            mp4WalkBoxes(view, childOffset + childHeader, childOffset + childSize, (grandType, grandOffset, grandHeader, grandSize) => {
              if (grandType === 'tkhd') {
                trackId = view.getUint8(grandOffset + grandHeader) === 1 ? view.getUint32(grandOffset + grandHeader + 20) : view.getUint32(grandOffset + grandHeader + 12);
              } else if (grandType === 'mdia') {
                mp4WalkBoxes(view, grandOffset + grandHeader, grandOffset + grandSize, (mdiaType, mdiaOffset, mdiaHeader, mdiaSize) => {
                  if (mdiaType === 'hdlr') {
                    isAudio = String.fromCharCode(view.getUint8(mdiaOffset + mdiaHeader + 8), view.getUint8(mdiaOffset + mdiaHeader + 9), view.getUint8(mdiaOffset + mdiaHeader + 10), view.getUint8(mdiaOffset + mdiaHeader + 11)) === 'soun';
                  } else if (mdiaType === 'mdhd') {
                    trakTimescale = view.getUint8(mdiaOffset + mdiaHeader) === 1 ? view.getUint32(mdiaOffset + mdiaHeader + 20) : view.getUint32(mdiaOffset + mdiaHeader + 12);
                  } else if (mdiaType === 'minf') {
                    mp4WalkBoxes(view, mdiaOffset + mdiaHeader, mdiaOffset + mdiaSize, (minfType, minfOffset, minfHeader, minfSize) => {
                      if (minfType !== 'stbl') return;
                      mp4WalkBoxes(view, minfOffset + minfHeader, minfOffset + minfSize, (stblType, stblOffset, stblHeader, stblSize) => {
                        if (stblType !== 'stsd') return;
                        mp4WalkBoxes(view, stblOffset + stblHeader + 8, stblOffset + stblSize, (stsdType, stsdOffset, stsdHeader, stsdSize) => {
                          if (stsdType !== 'mp4a') return;
                          numberOfChannels = view.getUint16(stsdOffset + stsdHeader + 16);
                          sampleRate = view.getUint32(stsdOffset + stsdHeader + 24) >>> 16;
                          mp4WalkBoxes(view, stsdOffset + stsdHeader + 28, stsdOffset + stsdSize, (mp4aType, mp4aOffset, mp4aHeader, mp4aSize) => {
                            if (mp4aType === 'esds') description = mp4EsdsDescription(view, mp4aOffset + mp4aHeader + 4, mp4aOffset + mp4aSize);
                          });
                        });
                      });
                    });
                  }
                });
              }
            });
            if (isAudio && trackId) {
              audioTrackId = trackId;
              timescale = trakTimescale;
            }
          } else if (childType === 'mvex') {
            mp4WalkBoxes(view, childOffset + childHeader, childOffset + childSize, (mvexType, mvexOffset, mvexHeader) => {
              if (mvexType === 'trex') defaultSampleDuration = view.getUint32(mvexOffset + mvexHeader + 12);
            });
          }
        });
      } else if (type === 'moof') {
        mp4WalkBoxes(view, offset + headerSize, offset + size, (trafType, trafOffset, trafHeader, trafSize) => {
          if (trafType !== 'traf') return;
          let trackId = 0;
          let tfDefaultDuration = 0;
          let tfDefaultSize = 0;
          let trun = null;
          mp4WalkBoxes(view, trafOffset + trafHeader, trafOffset + trafSize, (trafChildType, trafChildOffset, trafChildHeader) => {
            if (trafChildType === 'tfhd') {
              const flags = view.getUint32(trafChildOffset + trafChildHeader) & 0xffffff;
              trackId = view.getUint32(trafChildOffset + trafChildHeader + 4);
              let pos = trafChildOffset + trafChildHeader + 8;
              if (flags & 0x000001) pos += 8;
              if (flags & 0x000002) pos += 4;
              if (flags & 0x000008) { tfDefaultDuration = view.getUint32(pos); pos += 4; }
              if (flags & 0x000010) { tfDefaultSize = view.getUint32(pos); pos += 4; }
            } else if (trafChildType === 'trun') {
              const version = view.getUint8(trafChildOffset + trafChildHeader);
              const flags = view.getUint32(trafChildOffset + trafChildHeader) & 0xffffff;
              const count = view.getUint32(trafChildOffset + trafChildHeader + 4);
              let pos = trafChildOffset + trafChildHeader + 8;
              let dataOffset = 0;
              const hasDataOffset = Boolean(flags & 0x000001);
              if (hasDataOffset) { dataOffset = view.getInt32(pos); pos += 4; }
              if (flags & 0x000004) pos += 4;
              const sizes = [];
              const durations = [];
              for (let index = 0; index < count; index += 1) {
                if (flags & 0x000100) { durations.push(view.getUint32(pos)); pos += 4; }
                if (flags & 0x000200) { sizes.push(view.getUint32(pos)); pos += 4; }
                if (flags & 0x000400) pos += 4;
                if (flags & 0x000800) pos += version === 1 ? 4 : 4;
              }
              trun = { count, dataOffset, hasDataOffset, sizes, durations, moofOffset: offset, moofSize: size };
            }
          });
          if (trun) truns.push({ trackId, trun, defaultDuration: tfDefaultDuration, defaultSize: tfDefaultSize });
        });
      }
    });
    if (!audioTrackId || !timescale || !sampleRate || !numberOfChannels || !description) return null;
    const samples = [];
    for (const entry of truns) {
      if (entry.trackId !== audioTrackId) continue;
      const { trun, defaultDuration, defaultSize } = entry;
      let position = trun.hasDataOffset ? trun.moofOffset + trun.dataOffset : trun.moofOffset + trun.moofSize;
      for (let index = 0; index < trun.count; index += 1) {
        const size = trun.sizes[index] || defaultSize;
        if (!size || position + size > view.byteLength) return null;
        const duration = (trun.durations.length && trun.durations[index]) || defaultDuration || defaultSampleDuration;
        if (!duration) return null;
        samples.push({ data: new Uint8Array(bytes.buffer, bytes.byteOffset + position, size), duration });
        position += size;
      }
    }
    if (!samples.length) return null;
    return { description, sampleRate, numberOfChannels, timescale, samples };
  }

  async function transcodeAudioToMp3(chunks, total, onStatus) {
    if (typeof AudioDecoder === 'undefined') {
      throw new Error('当前浏览器不支持转码，请选择 m4a 格式');
    }
    const merged = new Uint8Array(total);
    let written = 0;
    for (const part of chunks) { merged.set(part, written); written += part.byteLength; }
    const parsed = parseFragmentedMp4Audio(merged);
    if (!parsed) throw new Error('无法解析 B站音频流（片段数据不完整），请改用 m4a 格式');
    const { sampleRate, numberOfChannels, timescale, samples, description } = parsed;
    if (sampleRate !== 44100 && sampleRate !== 48000) throw new Error(`B站音频采样率 ${sampleRate}Hz 不支持转 MP3，请改用 m4a 格式`);
    if (numberOfChannels > 2) throw new Error('B站音频声道数超过立体声，不支持转 MP3，请改用 m4a 格式');
    if (((description[0] >> 3) & 0x1f) !== 2) throw new Error('B站音频编码不支持转 MP3，请改用 m4a 格式');
    const decodeSupport = await AudioDecoder.isConfigSupported({ codec: 'mp4a.40.2', sampleRate, numberOfChannels, description }).catch(() => ({ supported: false }));
    if (!decodeSupport?.supported) throw new Error('当前浏览器不支持 AAC 解码，无法转 MP3，请改用 m4a 格式');
    if (typeof window !== 'undefined' && window.lamejs?.Mp3Encoder) {
      return transcodeWithLamejs(parsed, onStatus);
    }
    return transcodeWithWebCodecsEncoder(parsed, onStatus);
  }

  async function transcodeWithLamejs(parsed, onStatus) {
    const { sampleRate, numberOfChannels, timescale, samples } = parsed;
    const encoder = new window.lamejs.Mp3Encoder(numberOfChannels, sampleRate, 192);
    const encoded = [];
    const totalFrames = samples.length * 1024;
    let decodedFrames = 0;
    let timestampMicros = 0;
    let failed = false;
    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    return new Promise((resolve, reject) => {
      const decoder = new AudioDecoder({
        output: (audioData) => {
          if (failed) { audioData.close(); return; }
          const frames = audioData.numberOfFrames;
          const planar = audioData.format.includes('planar');
          const interleaved = !planar ? new Float32Array(frames * numberOfChannels) : null;
          if (interleaved) audioData.copyTo(interleaved, { planeIndex: 0, format: 'f32' });
          const left = new Int16Array(frames);
          const right = numberOfChannels > 1 ? new Int16Array(frames) : null;
          for (let channel = 0; channel < numberOfChannels; channel += 1) {
            const target = channel === 0 ? left : right;
            if (planar) {
              const plane = new Float32Array(frames);
              audioData.copyTo(plane, { planeIndex: channel, format: 'f32-planar' });
              for (let index = 0; index < frames; index += 1) {
                const value = Math.round(plane[index] * 32767);
                target[index] = value > 32767 ? 32767 : value < -32768 ? -32768 : value;
              }
            } else {
              for (let index = 0; index < frames; index += 1) {
                const value = Math.round(interleaved[index * numberOfChannels + channel] * 32767);
                target[index] = value > 32767 ? 32767 : value < -32768 ? -32768 : value;
              }
            }
          }
          const bytes = right ? encoder.encodeBuffer(left, right) : encoder.encodeBuffer(left);
          encoded.push(new Uint8Array(bytes));
          audioData.close();
          decodedFrames += frames;
          const percent = Math.min(100, Math.round((decodedFrames / totalFrames) * 100));
          onStatus?.(`正在转码为 MP3（${percent}%）`, percent);
        },
        error: (err) => {
          failed = true;
          reject(new Error(`音频解码失败：${err?.message || err}`));
        },
      });
      decoder.configure({ codec: 'mp4a.40.2', sampleRate, numberOfChannels, description: parsed.description });
      (async () => {
        try {
          for (const sample of samples) {
            if (failed) break;
            while (decoder.decodeQueueSize > 8) await sleep(10);
            const chunk = new EncodedAudioChunk({ type: 'key', timestamp: timestampMicros, data: sample.data });
            timestampMicros += Math.round((sample.duration * 1e6) / timescale);
            decoder.decode(chunk);
          }
          if (!failed) await decoder.flush();
          if (!failed) {
            encoded.push(new Uint8Array(encoder.flush()));
            resolve(mergeByteArrays(encoded));
          }
        } catch (err) {
          failed = true;
          reject(err);
        } finally {
          try { decoder.close(); } catch {}
        }
      })();
    });
  }

  async function transcodeWithWebCodecsEncoder(parsed, onStatus) {
    const { sampleRate, numberOfChannels, timescale, samples, description } = parsed;
    if (typeof AudioEncoder === 'undefined') throw new Error('当前浏览器不支持 MP3 编码，请改用 m4a 格式');
    const encoderSupport = await AudioEncoder.isConfigSupported({ codec: 'mp3', sampleRate, numberOfChannels, bitrate: 192000 }).catch(() => ({ supported: false }));
    if (!encoderSupport?.supported) throw new Error('当前浏览器不支持 MP3 编码，请改用 m4a 格式');
    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    const encoded = [];
    const decoderQueue = [];
    let encodePending = 0;
    let decodedFrames = 0;
    const totalFrames = samples.length * 1024;
    let timestampMicros = 0;
    let failed = false;
    return new Promise((resolve, reject) => {
      const fail = (err) => { if (!failed) { failed = true; reject(err); } };
      const encoder = new AudioEncoder({
        output: (chunk) => {
          const bytes = new Uint8Array(chunk.byteLength);
          chunk.copyTo(bytes);
          encoded.push(bytes);
          encodePending -= 1;
        },
        error: (err) => fail(new Error(`MP3 转码失败：${err?.message || err}`)),
      });
      encoder.configure({ codec: 'mp3', sampleRate, numberOfChannels, bitrate: 192000 });
      const feedEncoder = () => {
        while (decoderQueue.length && encoder.encodeQueueSize < 8) {
          const audioData = decoderQueue.shift();
          encodePending += 1;
          encoder.encode(audioData);
          audioData.close();
        }
      };
      const decoder = new AudioDecoder({
        output: (audioData) => {
          if (failed) { audioData.close(); return; }
          decoderQueue.push(audioData);
          decodedFrames += audioData.numberOfFrames;
          const percent = Math.min(100, Math.round((decodedFrames / totalFrames) * 100));
          onStatus?.(`正在转码为 MP3（${percent}%）`, percent);
          feedEncoder();
        },
        error: (err) => fail(new Error(`音频解码失败：${err?.message || err}`)),
      });
      decoder.configure({ codec: 'mp4a.40.2', sampleRate, numberOfChannels, description });
      (async () => {
        try {
          for (const sample of samples) {
            if (failed) break;
            while (encoder.encodeQueueSize >= 8) await sleep(10);
            const chunk = new EncodedAudioChunk({ type: 'key', timestamp: timestampMicros, data: sample.data });
            timestampMicros += Math.round((sample.duration * 1e6) / timescale);
            decoder.decode(chunk);
          }
          if (!failed) await decoder.flush();
          while (!failed && (decoderQueue.length || encoder.encodeQueueSize > 0 || encodePending > 0)) {
            feedEncoder();
            await sleep(10);
          }
          if (!failed) {
            await encoder.flush();
            resolve(mergeByteArrays(encoded));
          }
        } catch (err) {
          fail(err);
        } finally {
          try { encoder.close(); } catch {}
          try { decoder.close(); } catch {}
        }
      })();
    });
  }

  function oggCrc32(bytes) {
    let crc = 0;
    for (let index = 0; index < bytes.length; index += 1) {
      crc ^= bytes[index] << 24;
      for (let bit = 0; bit < 8; bit += 1) {
        crc = (crc & 0x80000000) ? ((crc << 1) ^ 0x04c11db7) : (crc << 1);
        crc >>>= 0;
      }
    }
    return crc >>> 0;
  }

  // 纯 JS 的 Ogg Opus 封装器：把 AudioEncoder 输出的裸 opus 包拼成 ffmpeg 可读的
  // Ogg 流（RFC 7845）。opus 不支持 44.1kHz，且 ffmpeg 没有 raw-opus demuxer，
  // 所以必须先把音频重采样到 16kHz 再封装 Ogg。
  class OggOpusMuxer {
    constructor(serial = 0x42494c49) {
      this.serial = serial >>> 0;
      this.pageSeq = 0;
      this.granule = 0;
      this.pages = [];
      this.pending = [];
      this.pendingBytes = 0;
      this.pendingGranule = 0;
      this.started = false;
    }
    _flushPage(headerType, forcedGranule = null) {
      if (!this.pending.length) return;
      const granule = forcedGranule !== null ? forcedGranule : this.granule + this.pendingGranule;
      const lacing = [];
      for (const packet of this.pending) {
        let rest = packet.length;
        do { lacing.push(Math.min(255, rest)); rest = Math.max(0, rest - 255); } while (rest > 0);
      }
      const page = new Uint8Array(27 + lacing.length + this.pendingBytes);
      page[0] = 0x4f; page[1] = 0x67; page[2] = 0x67; page[3] = 0x53;
      page[4] = 0; page[5] = headerType;
      const view = new DataView(page.buffer);
      view.setUint32(6, granule >>> 0, true);
      view.setUint32(10, Math.floor(granule / 0x100000000), true);
      view.setUint32(14, this.serial, true);
      view.setUint32(18, this.pageSeq, true);
      view.setUint32(22, 0, true);
      page[26] = lacing.length;
      page.set(lacing, 27);
      let offset = 27 + lacing.length;
      for (const packet of this.pending) { page.set(packet, offset); offset += packet.length; }
      view.setUint32(22, oggCrc32(page), true);
      this.pages.push(page);
      this.pageSeq += 1;
      this.granule = granule;
      this.pending = [];
      this.pendingBytes = 0;
      this.pendingGranule = 0;
    }
    writeOpusHead(preSkip, inputSampleRate) {
      const head = new Uint8Array(19);
      head.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0);
      head[8] = 1;
      head[9] = 1;
      const view = new DataView(head.buffer);
      view.setUint16(10, preSkip, true);
      view.setUint32(12, inputSampleRate, true);
      this.pending = [head];
      this.pendingBytes = 19;
      this._flushPage(0x02, 0);
      this.started = true;
    }
    writeOpusTags() {
      const vendor = 'bili-summary';
      const tags = new Uint8Array(8 + 4 + vendor.length + 4);
      tags.set([0x4f, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73], 0);
      const view = new DataView(tags.buffer);
      view.setUint32(8, vendor.length, true);
      tags.set(new TextEncoder().encode(vendor), 12);
      view.setUint32(12 + vendor.length, 0, true);
      this.pending = [tags];
      this.pendingBytes = tags.length;
      this._flushPage(0);
    }
    pushPacket(bytes, granuleDelta) {
      if (!this.started) throw new Error('ogg muxer not started');
      this.pending.push(bytes);
      this.pendingBytes += bytes.length;
      this.pendingGranule += granuleDelta;
      if (this.pending.length >= 255 || this.pendingBytes >= 4096) this._flushPage(0);
    }
    end() {
      if (this.pending.length) this._flushPage(0);
      const last = this.pages[this.pages.length - 1];
      if (last) {
        last[5] |= 0x04;
        const view = new DataView(last.buffer);
        view.setUint32(22, 0, true);
        view.setUint32(22, oggCrc32(last), true);
      }
      const total = this.pages.reduce((sum, page) => sum + page.length, 0);
      const merged = new Uint8Array(total);
      let offset = 0;
      for (const page of this.pages) { merged.set(page, offset); offset += page.length; }
      return merged;
    }
  }

  const AUDIO_UPLOAD_SPEED = 1.5;
  const AUDIO_UPLOAD_PREPROCESS = 'wsola-opus16k-v1';

  // 把 B站 fMP4 音频解码 → 重采样 16kHz 单声道 → WSOLA 1.5x 保持音调
  // 时间压缩 → opus 编码 → Ogg 封装。
  // 任何环节失败返回 null，由调用方回退到原始流直传（不动服务端格式）。
  async function transcodeAudioToOggOpus(chunks, total, onStatus) {
    if (typeof AudioDecoder === 'undefined' || typeof AudioEncoder === 'undefined' || typeof OfflineAudioContext === 'undefined') return null;
    if (typeof globalThis.BiliAudioTimeStretch?.timeStretchPreservePitch !== 'function') return null;
    try {
      const support = await AudioEncoder.isConfigSupported({ codec: 'opus', sampleRate: 16000, numberOfChannels: 1, bitrate: 24000 }).catch(() => ({ supported: false }));
      if (!support?.supported) return null;
    } catch (_) { return null; }
    const merged = new Uint8Array(total);
    let written = 0;
    for (const part of chunks) { merged.set(part, written); written += part.byteLength; }
    const parsed = parseFragmentedMp4Audio(merged);
    if (!parsed) return null;
    const { sampleRate, numberOfChannels, timescale, samples, description } = parsed;
    if (sampleRate !== 44100 && sampleRate !== 48000) return null;
    if (numberOfChannels > 2) return null;
    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    const muxer = new OggOpusMuxer();
    muxer.writeOpusHead(312, 16000);
    muxer.writeOpusTags();
    const RESAMPLE_CHUNK_FRAMES = 30 * sampleRate;
    const totalFrames = samples.length * 1024;
    let failed = false;
    let decodedFrames = 0;
    let encodedFrames = 0;
    let timestampMicros = 0;
    let opusPrevTimestamp = null;
    let accumStart = 0;
    const accumCapacity = RESAMPLE_CHUNK_FRAMES * 2;
    const leftAccum = new Float32Array(accumCapacity);
    const rightAccum = numberOfChannels > 1 ? new Float32Array(accumCapacity) : null;
    return new Promise((resolve) => {
      const encoder = new AudioEncoder({
        output: (chunk) => {
          if (failed) return;
          const bytes = new Uint8Array(chunk.byteLength);
          chunk.copyTo(bytes);
          if (opusPrevTimestamp === null) opusPrevTimestamp = chunk.timestamp;
          const deltaUs = chunk.timestamp - opusPrevTimestamp;
          opusPrevTimestamp = chunk.timestamp;
          muxer.pushPacket(bytes, deltaUs > 0 ? Math.max(480, Math.round((deltaUs / 1000) * 48)) : 960);
        },
        error: () => { failed = true; },
      });
      try {
        encoder.configure({ codec: 'opus', sampleRate: 16000, numberOfChannels: 1, bitrate: 24000 });
      } catch (_) {
        try { encoder.close(); } catch {}
        resolve(null);
        return;
      }
      const resampleAndEncode = async (frameCount) => {
        if (failed || !frameCount) return;
        try {
          const outFrames = Math.max(1, Math.round(frameCount * 16000 / sampleRate));
          const offline = new OfflineAudioContext({ numberOfChannels: 1, length: outFrames, sampleRate: 16000 });
          const source = offline.createBuffer(numberOfChannels, frameCount, sampleRate);
          source.copyToChannel(leftAccum, 0, 0);
          if (rightAccum) source.copyToChannel(rightAccum, 1, 0);
          const node = offline.createBufferSource();
          node.buffer = source;
          node.connect(offline.destination);
          node.start(0);
          const rendered = await offline.startRendering();
          const resampledPcm = rendered.getChannelData(0);
          const pcm = globalThis.BiliAudioTimeStretch?.timeStretchPreservePitch(
            resampledPcm,
            AUDIO_UPLOAD_SPEED,
            16000,
          );
          if (!(pcm instanceof Float32Array) || !pcm.length) throw new Error('音频变速失败');
          const data = new AudioData({ format: 'f32-planar', sampleRate: 16000, numberOfFrames: pcm.length, numberOfChannels: 1, timestamp: timestampMicros, data: pcm });
          timestampMicros += Math.round(pcm.length * 1e6 / 16000);
          while (encoder.encodeQueueSize >= 8 && !failed) await sleep(10);
          encoder.encode(data);
          data.close();
          encodedFrames += frameCount;
          const percent = Math.min(90, Math.round(40 + (encodedFrames / totalFrames) * 50));
          onStatus?.(`正在 1.5 倍速压缩音频（${percent}%）`, percent);
        } catch (_) {
          failed = true;
        }
      };
      const decoder = new AudioDecoder({
        output: (audioData) => {
          if (failed) { audioData.close(); return; }
          const frames = audioData.numberOfFrames;
          const planar = audioData.format.includes('planar');
          if (planar) {
            audioData.copyTo(leftAccum.subarray(accumStart, accumStart + frames), { planeIndex: 0, format: 'f32-planar' });
            if (rightAccum) audioData.copyTo(rightAccum.subarray(accumStart, accumStart + frames), { planeIndex: 1, format: 'f32-planar' });
          } else {
            const interleaved = new Float32Array(frames * numberOfChannels);
            audioData.copyTo(interleaved, { planeIndex: 0, format: 'f32' });
            for (let index = 0; index < frames; index += 1) {
              leftAccum[accumStart + index] = interleaved[index * numberOfChannels];
              if (rightAccum) rightAccum[accumStart + index] = interleaved[index * numberOfChannels + 1];
            }
          }
          audioData.close();
          accumStart += frames;
          decodedFrames += frames;
          const percent = Math.min(40, Math.round((decodedFrames / totalFrames) * 40));
          onStatus?.(`正在读取音频（${percent}%）`, percent);
          if (accumStart >= RESAMPLE_CHUNK_FRAMES) {
            const frameCount = Math.min(RESAMPLE_CHUNK_FRAMES, accumStart);
            const extra = accumStart - frameCount;
            leftAccum.copyWithin(0, frameCount, accumStart);
            if (rightAccum) rightAccum.copyWithin(0, frameCount, accumStart);
            accumStart = extra;
            void resampleAndEncode(frameCount);
          }
        },
        error: () => { failed = true; },
      });
      try {
        decoder.configure({ codec: 'mp4a.40.2', sampleRate, numberOfChannels, description });
      } catch (_) {
        try { decoder.close(); } catch {}
        try { encoder.close(); } catch {}
        resolve(null);
        return;
      }
      (async () => {
        try {
          for (const sample of samples) {
            if (failed) break;
            while (decoder.decodeQueueSize > 8) await sleep(10);
            const chunk = new EncodedAudioChunk({ type: 'key', timestamp: timestampMicros, data: sample.data });
            timestampMicros += Math.round((sample.duration * 1e6) / timescale);
            decoder.decode(chunk);
          }
          await decoder.flush();
          if (accumStart > 0 && !failed) await resampleAndEncode(accumStart);
          while (encoder.encodeQueueSize > 0 && !failed) await sleep(10);
          if (failed) {
            resolve(null);
            return;
          }
          await encoder.flush();
          const ogg = muxer.end();
          onStatus?.('正在 1.5 倍速压缩音频（100%）', 100);
          resolve(ogg);
        } catch (_) {
          resolve(null);
        } finally {
          try { decoder.close(); } catch {}
          try { encoder.close(); } catch {}
        }
      })();
    });
  }

  async function downloadMediaPages(kind, allPages) {
    return enqueueMediaDownload(kind, allPages);
  }

  function updateMediaDownloadStatus(message, percent = 0, completed = false, folderName = '') {
    els.toolboxDownloadComplete.classList.toggle('hidden', !completed);
    if (completed) {
      els.toolboxDownloadStatus.classList.add('hidden');
      els.toolboxDownloadCompleteMessage.textContent = `已下载到${folderName || '所选'}文件夹✅️`;
      return;
    }
    els.toolboxDownloadStatus.classList.remove('hidden');
    els.toolboxDownloadMessage.textContent = message;
    els.toolboxDownloadPercent.textContent = `${Math.max(0, Math.min(100, Math.round(percent)))}%`;
    els.toolboxDownloadProgressFill.style.width = `${Math.max(0, Math.min(100, percent))}%`;
  }

  function mediaQueueSummary(prefix = '') {
    const queuedFiles = mediaDownloadQueue.reduce((sum, task) => sum + task.pages.length, 0);
    const activeFiles = activeMediaDownload ? activeMediaDownload.pages.length - activeMediaDownload.pageIndex : 0;
    const count = queuedFiles + activeFiles;
    return `${prefix}${count > 0 ? `，${count} 个视频/音频文件排队中` : ''}`;
  }

  async function enqueueMediaDownload(kind, allPages) {
    if (allPages && !canUseToolboxPremiumDownload()) throw new Error('当前仅笃学会员、博学会员支持批量下载');
    const baseContext = createToolboxContext();
    const directory = await requireToolboxDirectory();
    const collectionEpisodes = allPages && toolboxCollectionEpisodes.length > 1 ? toolboxCollectionEpisodes : [];
    const context = collectionEpisodes.length
      ? { ...baseContext, collection: true, collectionTitle: toolboxCollectionTitle, pages: collectionEpisodes.map((episode) => ({ cid: episode.cid, page: episode.page, part: episode.part })) }
      : baseContext;
    const pages = collectionEpisodes.length
      ? collectionEpisodes
      : [{ cid: context.cid, page: context.page, part: context.part || context.title }];
    const quality = Number(els.toolboxVideoQuality.value || 80);
    const format = kind === 'audio' ? String(els.toolboxAudioFormat.value || 'm4a') : '';
    updateMediaDownloadStatus(`正在提交 ${pages.length} 个${kind === 'audio' ? '音频' : '视频'}任务...`, 0);
    mediaDownloadQueue.push({ context, directory, pages, kind, quality, format, pageIndex: 0 });
    updateMediaDownloadStatus(mediaQueueSummary('任务已提交'), 0);
    void processMediaDownloadQueue();
  }

  async function processMediaDownloadQueue() {
    if (activeMediaDownload) return;
    activeMediaDownload = mediaDownloadQueue.shift() || null;
    if (!activeMediaDownload) return;
    const task = activeMediaDownload;
    const filenames = [];
    try {
      for (task.pageIndex = 0; task.pageIndex < task.pages.length; task.pageIndex += 1) {
        const page = task.pages[task.pageIndex];
        const itemBvid = page.bvid || task.context.bvid;
        const response = await chrome.runtime.sendMessage({
          action: 'getBilibiliToolboxData', tabId: task.context.tabId, bvid: itemBvid, cid: String(page.cid), quality: task.quality,
        });
        if (!response?.success) throw new Error(response?.error || `P${page.page || 1} 无法读取媒体地址`);
        const pageBase = task.pageIndex / task.pages.length;
        const pageShare = 1 / task.pages.length;
        filenames.push(...await writeBilibiliMedia(task.directory, task.context, page, task.kind, task.quality, response.data,
          (segmentIndex, segmentCount, loaded, total) => {
            const segmentProgress = (segmentIndex + Math.min(1, loaded / Math.max(1, total))) / segmentCount;
            const percent = (pageBase + segmentProgress * pageShare) * 100;
            updateMediaDownloadStatus(mediaQueueSummary(`正在下载 ${task.context.title} P${page.page || 1}`), percent);
          },
          task.format,
          (message, percent) => {
            const merged = (pageBase + (percent / 100) * pageShare) * 100;
            updateMediaDownloadStatus(mediaQueueSummary(message), merged);
          }));
      }
      if (mediaDownloadQueue.length === 0) {
        updateMediaDownloadStatus('', 100, true, task.directory.name);
      } else {
        updateMediaDownloadStatus(mediaQueueSummary('正在等待下一项'), 0);
      }
    } catch (err) {
      updateMediaDownloadStatus(mediaQueueSummary(`下载失败：${err.message || '未知错误'}`), 0);
      showToast(err.message || '媒体下载失败', 'error', 5000);
    } finally {
      activeMediaDownload = null;
      if (mediaDownloadQueue.length) void processMediaDownloadQueue();
    }
  }

  function setToolboxFavoritesState(message) {
    els.toolboxFavorites.textContent = '';
    const state = document.createElement('div');
    state.className = 'toolbox-state';
    state.textContent = message;
    els.toolboxFavorites.appendChild(state);
  }

  function appendWatchLaterButton() {
    const button = document.createElement('button');
    button.type = 'button';
    button.className = 'toolbox-favorite toolbox-favorite-watchlater';
    const name = document.createElement('strong');
    name.textContent = t('watchLater');
    const count = document.createElement('span');
    count.textContent = t('watchLaterHint');
    button.append(name, count);
    button.addEventListener('click', async () => {
      const sourceTab = currentTab || await getActiveBilibiliTab();
      const createOptions = {
        url: 'https://www.bilibili.com/watchlater/list?spm_id_from=333.1387.0.0#/list',
        active: true,
      };
      if (sourceTab?.windowId !== undefined) createOptions.windowId = sourceTab.windowId;
      if (sourceTab?.index !== undefined) createOptions.index = sourceTab.index + 1;
      await chrome.tabs.create(createOptions);
    });
    els.toolboxFavorites.appendChild(button);
  }

  function renderFavoriteFolders(list, mid) {
    els.toolboxFavorites.textContent = '';
    appendWatchLaterButton();
    list.forEach((folder) => {
      const button = document.createElement('button');
      button.type = 'button';
      button.className = 'toolbox-favorite';
      const name = document.createElement('strong');
      name.textContent = folder.title || '未命名收藏夹';
      const count = document.createElement('span');
      count.textContent = `${Number(folder.media_count || 0)} 个视频`;
      button.append(name, count);
      button.addEventListener('click', async () => {
        const sourceTab = currentTab || await getActiveBilibiliTab();
        const createOptions = {
          url: `https://space.bilibili.com/${mid}/favlist?fid=${folder.id}&ftype=create`,
          active: true,
        };
        if (sourceTab?.windowId !== undefined) createOptions.windowId = sourceTab.windowId;
        if (sourceTab?.index !== undefined) createOptions.index = sourceTab.index + 1;
        await chrome.tabs.create(createOptions);
      });
      els.toolboxFavorites.appendChild(button);
    });
  }

  async function renderBilibiliFavorites() {
    const stored = toolboxFavoritesCache || (await chrome.storage.local.get(TOOLBOX_FAVORITES_CACHE_KEY))[TOOLBOX_FAVORITES_CACHE_KEY];
    if (stored?.mid && Array.isArray(stored.list) && stored.list.length && Date.now() - Number(stored.updatedAt || 0) < TOOLBOX_FAVORITES_CACHE_TTL) {
      toolboxFavoritesCache = stored;
      renderFavoriteFolders(stored.list, stored.mid);
      return;
    }
    setToolboxFavoritesState('正在读取 B站收藏夹...');
    const nav = await fetchBilibiliPageJson('https://api.bilibili.com/x/web-interface/nav');
    if (!nav?.isLogin || !nav.mid) {
      setToolboxFavoritesState('请先在当前 B站页面登录账号');
      return;
    }
    const folders = await fetchBilibiliPageJson(`https://api.bilibili.com/x/v3/fav/folder/created/list-all?up_mid=${nav.mid}`);
    const list = Array.isArray(folders?.list) ? folders.list : [];
    if (!list.length) {
      setToolboxFavoritesState('当前账号暂无收藏夹');
      appendWatchLaterButton();
      return;
    }
    toolboxFavoritesCache = { mid: String(nav.mid), list, updatedAt: Date.now() };
    await chrome.storage.local.set({ [TOOLBOX_FAVORITES_CACHE_KEY]: toolboxFavoritesCache });
    renderFavoriteFolders(list, nav.mid);
  }

  async function saveCollectionWithFilePicker(resultMeta, content) {
    if (typeof window.showSaveFilePicker !== 'function') return { handled: false };
    const filename = sanitizeCollectionFilename(resultMeta.title) + '.' + collectionTextExtension();
    let fileHandle = null;
    try {
      fileHandle = await window.showSaveFilePicker({
        id: 'bili-summary-collection',
        suggestedName: filename,
        types: [{
          description: 'TXT 文本',
          accept: { 'text/plain': ['.txt', '.md'] },
        }],
      });
      const writable = await fileHandle.createWritable();
      await writable.write('视频链接: ' + resultMeta.url + '\n\n' + content);
      await writable.close();
      return { handled: true, saved: true, filename };
    } catch (err) {
      if (err?.name === 'AbortError') return { handled: true, saved: false };
      if (!fileHandle) return { handled: false };
      return { handled: true, saved: false, error: collectionErrorMessage(err) };
    }
  }

  async function saveCollectionWithPicker(resultMeta, content) {
    if (typeof window.showDirectoryPicker !== 'function') {
      return saveCollectionWithFilePicker(resultMeta, content);
    }
    try {
      if (!collectionDirectoryHandle) {
        const selectedHandle = await selectCollectionDirectory();
        if (!selectedHandle) return { handled: true, saved: false };
      }
      if (!(await ensureCollectionDirectoryPermission(true))) {
        return { handled: true, saved: false, error: t('collectionPermissionDenied') };
      }
      const filename = await writeCollectionFile(collectionDirectoryHandle, resultMeta, content);
      return { handled: true, saved: true, filename };
    } catch (err) {
      if (err?.name === 'AbortError') return { handled: true, saved: false };
      return { handled: true, saved: false, error: collectionErrorMessage(err) };
    }
  }

  // ============ 视频检测 ============
  async function detectVideo(opts = {}) {
    const { waitForSubtitle = false } = opts;
    const detectionId = ++videoDetectionId;
    const keepToolboxOpen = !els.toolboxPanel.classList.contains('hidden');
    await clearLegacySubtitleCache();
    const tab = await getActiveBilibiliTab();
    if (detectionId !== videoDetectionId) return;
    // A loading-only session cannot be reused here. A second detection increments
    // videoDetectionId and cancels the first subtitle request; returning early would
    // then leave nobody responsible for replacing “正在加载字幕...”.
    if (tab?.id === currentTab?.id && videoData?.bvid && (lastSubtitleText || lastSummary || isProcessing || isAudioTranscribing)) {
      try {
        const live = await chrome.tabs.sendMessage(tab.id, { action: 'getVideoInfo' });
        if (detectionId !== videoDetectionId) return;
        if (live?.success && live.data.bvid === videoData.bvid && String(live.data.cid || '') === String(videoData.cid || '')) {
          currentTab = tab;
          videoData = { ...videoData, ...live.data };
          applyPlaybackSpeedToTab();
          return;
        }
      } catch (_) {}
    }
    saveCurrentVideoSession();
    activeVideoSession = null;
    isProcessing = false;
    isAudioTranscribing = false;
    slowAsrQueued = false;
    isSubtitleLoading = true;
    if (keepToolboxOpen) refreshToolboxVideoContext({ loading: true });
    let detectionTimedOut = false;
    const detectionTimeout = setTimeout(() => {
      if (detectionId !== videoDetectionId) return;
      detectionTimedOut = true;
      isSubtitleLoading = false;
      setStatus('error', '❌', '网络有问题，请解决后刷新');
    }, 30000);
    lastSubtitleText = '';
    subtitleSegments = [];
    lastSubtitleWasTranscribedAudio = false;
    // 切换视频时终止上一视频未完成的字幕渐进追加，避免旧批次写进新内容
    stopSubtitleProgressiveRender();
    subtitleRenderedCount = 0;
    subtitleStaticLines = [];
    closeSubtitleSearch();
    lastSummary = '';
    lastSummaryTranscript = '';
    subtitleResultMeta = null;
    summaryResultMeta = null;
    estimatedSummaryCredits = 0;
    updateStartCreditTooltip();
    updateStudyActionState();
    followUpHistory = [];
    renderFollowUpDock();
    updateResultTabs('subtitle');
    els.progress.classList.add('hidden');
    els.result.classList.remove('hidden');
    els.subtitleView.textContent = '正在加载字幕...';
    updateStartButtonIdle();
    // 打开侧栏/切换视频时立即固定各模块位置：视频信息、风格与工具箱
    // 与字幕栏同时出现，避免先只显示字幕栏、等视频信息返回后才补出
    // 其他模块导致布局跳动。
    els.videoInfo.classList.remove('hidden');
    els.controls.classList.remove('hidden');
    if (!videoData) {
      els.videoInfo.classList.add('video-info-loading');
      els.videoThumb.classList.add('hidden');
      els.videoTitle.textContent = t('gettingVideoInfo');
      els.videoOwner.textContent = '';
      els.videoDuration.textContent = '';
      els.videoPage.textContent = '';
      els.videoPage.classList.add('hidden');
    }
    try {
      if (detectionTimedOut) return;
      if (!tab) {
        currentTab = null;
        videoData = null;
        if (keepToolboxOpen) refreshToolboxVideoContext();
        els.videoInfo.classList.add('hidden');
        els.controls.classList.add('hidden');
        els.result.classList.add('hidden');
        els.followUpDock.classList.add('hidden');
        els.historyPanel.classList.add('hidden');
        els.emptyActions?.classList.remove('hidden');
        clearStatus();
        return;
      }
      currentTab = tab;
      els.emptyActions?.classList.add('hidden');

      // getVideoInfo 的 hasSubtitle 只是一次即时探测，不能把暂态空列表当成
      // “该视频没有字幕”。这里仅等待视频身份就绪，字幕由下方独立重试并校验。
      const maxWait = waitForSubtitle ? 4 : 1;
      let response = null;
      for (let attempt = 0; attempt <= maxWait; attempt++) {
        if (attempt > 0) {
          setStatus('', '⏳', `等待视频页面就绪... (${attempt}/${maxWait})`);
          await new Promise(r => setTimeout(r, 1000));
        }
        try {
          response = await chrome.tabs.sendMessage(tab.id, { action: 'getVideoInfo' });
        } catch (e) {
          if (attempt === 0) {
            await chrome.runtime.sendMessage({ action: 'injectContentScript', tabId: tab.id });
            await new Promise((r) => setTimeout(r, 800));
            try { response = await chrome.tabs.sendMessage(tab.id, { action: 'getVideoInfo' }); } catch { response = null; }
          }
        }
        if (detectionTimedOut) return;
        if (response?.success) {
          break;
        } else if (attempt >= maxWait) {
          setStatus('error', '❌', response?.error || '无法获取视频信息');
          return;
        }
      }
      if (detectionId !== videoDetectionId) return;
      if (detectionTimedOut) return;
      if (!response || !response.success) { setStatus('error', '❌', response?.error || '无法获取视频信息'); return; }

      videoData = response.data;
      if (keepToolboxOpen) {
        refreshToolboxVideoContext({ loading: true });
        void refreshToolboxMetadata().finally(() => refreshToolboxVideoContext());
      }
      applyPlaybackSpeedToTab();
      const saved = videoSessions.get(videoSessionKey(videoData, tab));
      if (saved?.videoData?.bvid === videoData.bvid && String(saved.videoData?.cid || '') === String(videoData.cid || '')) {
        saved.tab = { id: tab.id, title: tab.title, url: tab.url };
        saved.videoData = { ...saved.videoData, ...videoData };
        restoreVideoSession(saved);
        return;
      }
      const historyMatch = await findHistorySummaryRecord(videoData.bvid, videoData.cid);
      if (historyMatch) {
        const restored = {
          ...historyMatch,
          tab: { id: tab.id, title: tab.title, url: tab.url },
          videoData: { ...historyMatch.videoData, ...videoData },
        };
        restoreVideoSession(restored);
        return;
      }
      showVideoInfo(videoData);
      updateSummaryCommentButton();
      els.statusBar.classList.add('hidden');
      els.controls.classList.remove('hidden');

      // 无论即时探测结果如何都直接取一次字幕文本。B 站播放器刚启动时
      // 字幕列表经常短暂为空，但几百毫秒后即可正常返回。
      els.result.classList.remove('hidden');
      els.subtitleView.textContent = '正在加载字幕...';
      renderFollowUpDock();
      els.btnStart.disabled = true;
      try {
        await loadSubtitleDirect({ maxAttempts: 5, detectionId });
      } finally {
        if (detectionId === videoDetectionId && !isProcessing && !isAudioTranscribing) {
          isSubtitleLoading = false;
          updateStartButtonIdle();
          settleSubtitleNotFoundIfStuck(detectionId);
        }
      }
    } catch (err) {
      if (detectionId !== videoDetectionId) return;
      setStatus('error', '❌', `检测失败: ${err.message}`);
    } finally {
      clearTimeout(detectionTimeout);
      if (detectionId === videoDetectionId) {
        isSubtitleLoading = false;
        settleSubtitleNotFoundIfStuck(detectionId);
      }
    }
  }

  function renderVideoTitleLink(d) {
    const titleText = toDisplayLanguage(d?.title || '');
    const bvid = String(d?.bvid || '');
    els.videoTitle.textContent = '';
    if (bvid) {
      const page = Math.max(1, Number(d?.page || 1));
      const link = document.createElement('a');
      link.className = 'video-title-link';
      link.href = 'https://www.bilibili.com/video/' + bvid + (page > 1 ? '?p=' + page : '');
      link.target = '_blank';
      link.rel = 'noopener noreferrer';
      link.textContent = titleText;
      els.videoTitle.appendChild(link);
    } else {
      els.videoTitle.textContent = titleText;
    }
  }

  function showVideoInfo(d) {
    els.videoInfo.classList.remove('hidden');
    els.videoInfo.classList.remove('video-info-loading');
    els.videoThumb.classList.toggle('hidden', !(d.pic || ''));
    if (d.pic) els.videoThumb.src = d.pic;
    renderVideoTitleLink(d);
    els.videoOwner.textContent = `${t('creator')}: ${d.owner || t('unknown')}`;
    els.videoDuration.textContent = `${t('duration')}: ${fmtDur(d.duration)}`;
    const pageCount = Array.isArray(d.pages) ? d.pages.length : 0;
    const currentPage = d.pages?.find((page) => String(page.cid || '') === String(d.cid || ''))
      || d.pages?.find((page) => Number(page.page || 0) === Number(d.page || 1));
    const partTitle = (currentPage?.part || d.part || '').trim();
    const titleText = String(d.title || '').trim();
    // 分 P 数量不可靠（历史恢复可能没有 pages）时，当前 P 标题与视频标题
    // 不同也可以确认是多分 P，避免该行消失。
    const isMultipart = pageCount > 1 || (partTitle && titleText && partTitle !== titleText);
    const hasMultipartInfo = isMultipart && partTitle;
    els.videoPage.textContent = hasMultipartInfo ? `分P：${partTitle}` : '';
    els.videoPage.classList.toggle('hidden', !hasMultipartInfo);
    reportVideoStats({ subtitleText: '' });
  }

  function getEstimatedTranscriptInputTokens(text) {
    const str = String(text || '');
    // Avoid RegExp#match here: for a long transcript it allocates an array with
    // one entry per CJK character just to obtain its length.
    let cjkChars = 0;
    for (let i = 0; i < str.length; i += 1) {
      const code = str.charCodeAt(i);
      if ((code >= 0x3400 && code <= 0x4dbf) ||
          (code >= 0x4e00 && code <= 0x9fff) ||
          (code >= 0x3040 && code <= 0x309f) ||
          (code >= 0x30a0 && code <= 0x30ff) ||
          (code >= 0xac00 && code <= 0xd7af) ||
          (code >= 0xf900 && code <= 0xfaff)) {
        cjkChars += 1;
      }
    }
    const nonCjkChars = str.length - cjkChars;
    const contentTokens = Math.ceil(cjkChars / 1.5 + nonCjkChars / 4);
    if (contentTokens <= 0) return 0;

    // 实际请求还包含系统提示词，以及标题、UP 主、时长等视频元数据。
    return contentTokens + 60;
  }

  function getEstimatedSummaryCredits(text) {
    const inputTokens = getEstimatedTranscriptInputTokens(text);
    if (inputTokens <= 0) return 0;

    // 可见总结字数不等于模型的 completion token；按所选档位比例估算输出。
    const outputRatios = {
      concise: 0.1,
      standard: 0.2,
      detailed: 0.3,
      custom: 0.2
    };
    const selectedStyle = els.styleSelect?.value || 'standard';
    const outputTokens = Math.min(
      MAX_OUTPUT_TOKENS_MAX,
      maxOutputTokens,
      Math.ceil(inputTokens * (outputRatios[selectedStyle] || 0.2))
    );
    const inputPrice = 1;
    const outputPrice = 4;
    return Math.max(1, Math.ceil((inputTokens * inputPrice + outputTokens * outputPrice) / 100));
  }

  function getSummaryInputCharCount(text) {
    return String(text || '').replace(/\s+/g, '').length;
  }

  // 字幕超过当前会员可处理上限时返回拦截提示文案（勤学会员 2 万字，其余 10 万字），
  // 未超限返回空串。文案与服务端 enforceSummaryMembershipLimits 保持一致。
  function summaryInputBlockMessage(text = lastSubtitleText) {
    const chars = getSummaryInputCharCount(text);
    if (currentUserTier === 'qinxue' && chars > QINXUE_STUDY_CHAR_LIMIT) {
      return '勤学会员暂不支持两万字以上总结，可复制字幕到deepseek总结或升级会员';
    }
    if (chars > SUMMARY_INPUT_CHAR_LIMIT) {
      return '暂不支持十万字以上总结，可复制到deepseek总结';
    }
    return '';
  }

  function updateEstimatedSummaryCredits(text) {
    estimatedSummaryCredits = getEstimatedSummaryCredits(text);
    updateStudyActionState();
    if (!isProcessing && !isAudioTranscribing) updateStartButtonIdle();
    else updateStartCreditTooltip();
  }

  async function ensureLocalSummaryCreditThreshold() {
    const quota = await refreshMembershipAccess();
    if (currentUserTier === 'admin') return true;
    const balance = quota?.remaining ?? currentCreditBalance;
    if (balance === null || balance === undefined) return true;
    // 只按服务端真实的放行线（剩余 >= 1，且服务端允许少量透支）预检，扣费权威在服务端。
    // 此前按“预估学分 × 2/3”预拦：详档/长字幕时门槛比服务端更严，且剩余会扣掉其他
    // 在途任务的预占额，导致学分仍是正数（页面显示也够）时“再次总结”被拒。
    return Number(balance) >= 1;
  }

  function formatCreditBalance(value) {
    if (value === null || value === undefined || value === '') return '-';
    const n = Math.max(0, Math.ceil(Number(value || 0)));
    if (n > 99999 && currentLanguage === 'en') return (n / 1000).toFixed(1) + 'k';
    if (n > 99999 && currentLanguage !== 'en') {
      const wan = n / 10000;
      return (wan >= 100 ? Math.round(wan) : Math.round(wan * 10) / 10).toLocaleString('zh-CN') + '万';
    }
    return String(n);
  }

  function updateCreditBalanceDisplay(value = currentCreditBalance) {
    currentCreditBalance = value;
    if (els.estimatedCreditValue) {
      els.estimatedCreditValue.textContent = formatCreditBalance(currentCreditBalance);
    }
  }

  function updateStartCreditTooltip() {
    const bilibiliLoginRequired = isBilibiliLoginRequiredForSubtitle();
    const blockMessage = summaryInputBlockMessage();
    const available = bilibiliLoginRequired || (!isProcessing && estimatedSummaryCredits > 0);
    els.startSummaryWrap?.classList.toggle('has-credit-estimate', available || Boolean(blockMessage));
    if (els.startCreditTooltip) {
      els.startCreditTooltip.textContent = blockMessage
        || (bilibiliLoginRequired
          ? 'B站账号未登录，无法获取字幕。'
          : (available ? tf('estimateCredits', { credits: estimatedSummaryCredits }) : ''));
    }
    if (els.btnStart) {
      if (available || blockMessage) els.btnStart.setAttribute('aria-describedby', 'start-credit-tooltip');
      else els.btnStart.removeAttribute('aria-describedby');
    }
  }

  function audioTranscriptionCredits(mode) {
    const perMinute = mode === 'fast' ? 40 : 10;
    return Math.max(1, Math.ceil(Math.max(0, Number(videoData?.duration || 0)) * perMinute / 60));
  }

  function estimatedAudioSummaryCredits() {
    const estimatedTranscriptChars = Math.ceil(Math.max(0, Number(videoData?.duration || 0)) / 60 * 200);
    return getEstimatedSummaryCredits('字'.repeat(estimatedTranscriptChars));
  }

  function estimatedAudioTotalCredits(mode) {
    return audioTranscriptionCredits(mode) + estimatedAudioSummaryCredits();
  }

  function isBilibiliLoginRequiredForSubtitle() {
    return Boolean(videoData
      && videoData.bilibiliLoggedIn !== true
      && videoData.hasSubtitle === false
      && !lastSubtitleText.trim());
  }

  function updateAudioSummaryActions() {
    const bilibiliLoginRequired = isBilibiliLoginRequiredForSubtitle();
    const noSubtitle = Boolean(videoData
      && videoData.hasSubtitle === false
      && !lastSubtitleText.trim()
      && !bilibiliLoginRequired);
    const subtitlePending = isSubtitleLoading || (isAudioTranscribing && !lastSubtitleText.trim());
    const audioButtons = [els.btnStartSlow, els.btnStartFast, els.btnStartKimi];
    audioButtons.forEach((button) => button?.classList.toggle('subtitle-pending-action', subtitlePending));
    els.startSummaryWrap?.classList.toggle('hidden', noSubtitle);
    els.audioSummaryActions?.classList.toggle('hidden', !noSubtitle);
    if (!noSubtitle) return;
    const slowUnsupported = Number(videoData.duration || 0) > SLOW_AUDIO_SUMMARY_MAX_DURATION_SECONDS;
    const kimiUnsupported = Number(videoData.duration || 0) > 2 * 60 * 60;
    // 每日音频总结总时长额度：勤学 1 小时、笃学 3 小时、博学 6 小时，音频总结与 fast 共用；
    // 勤学会员不可用 fast，管理员不限。
    const audioRemainingSeconds = Number(currentAudioSummaryQuota?.remaining_seconds ?? Number.POSITIVE_INFINITY);
    const audioDailyLimited = currentUserTier !== 'admin'
      && Number(videoData.duration || 0) > audioRemainingSeconds;
    const fastTierBlocked = currentUserTier === 'qinxue';
    els.btnStartSlow.disabled = !historyLoaded || subtitlePending || slowUnsupported || isProcessing
      || (isAudioTranscribing && !slowAsrQueued) || audioDailyLimited;
    els.btnStartFast.disabled = !historyLoaded || subtitlePending || isProcessing || isAudioTranscribing
      || audioDailyLimited || fastTierBlocked;
    els.btnStartKimi.disabled = !historyLoaded || subtitlePending || kimiUnsupported || kimiDownloadBusy;
    els.btnStartSlow.textContent = slowAsrQueued ? '取消排队' : '音频总结';
    const summaryCredits = estimatedAudioSummaryCredits();
    els.slowSummaryTooltip.textContent = slowUnsupported
      ? '音频总结当前只支持处理1小时以内的视频'
      : (audioDailyLimited
        ? `今日剩余额度不足（${Math.floor(audioRemainingSeconds / 60)}分钟）`
        : `预估总消耗${estimatedAudioTotalCredits('slow')}\n学分，详情右键`);
    els.fastSummaryTooltip.textContent = fastTierBlocked
      ? '仅支持笃学会员和博学会员'
      : (audioDailyLimited
        ? `今日剩余额度不足（${Math.floor(audioRemainingSeconds / 60)}分钟）`
        : `预估总消耗${estimatedAudioTotalCredits('fast')}\n学分，详情右键`);
    if (els.kimiSummaryTooltip) {
      els.kimiSummaryTooltip.textContent = kimiUnsupported
        ? 'Kimi音频总结只支持2小时以内的视频'
        : '保存压缩音频到收藏夹文件夹，并\n复制当前风格提示词；不消耗学分';
    }
  }

  function cancelSlowSummaryQueue() {
    if (!slowAsrQueued) return;
    slowAsrQueued = false;
    isAudioTranscribing = false;
    if (activeVideoSession) {
      activeVideoSession.runId = ++summaryRunId;
      activeVideoSession.slowAsrQueued = false;
      activeVideoSession.isAudioTranscribing = false;
    }
    void chrome.runtime.sendMessage({
      action: 'cancelVideoAudioTranscription',
      requestId: activeVideoSession?.audioRequestId,
      jobId: activeVideoSession?.audioSummaryJobId,
      tabId: currentTab?.id,
      bvid: videoData?.bvid,
      cid: videoData?.cid,
    });
    showSubtitleFailure('已取消慢速总结排队');
    updateAudioSummaryActions();
    updateStudyActionState();
  }

  function updateStartButtonIdle() {
    els.btnStart.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>' + t('start');
    els.btnStart.disabled = !historyLoaded || isSubtitleLoading || isProcessing || isAudioTranscribing
      || isBilibiliLoginRequiredForSubtitle() || Boolean(summaryInputBlockMessage());
    els.btnStart.classList.toggle('subtitle-pending-action', isSubtitleLoading);
    updateStartCreditTooltip();
    updateAudioSummaryActions();
  }

  function updateStartButtonProcessing() {
    els.btnStart.innerHTML = '<svg class="spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>' + t('summarizing');
    els.btnStart.disabled = true;
    els.btnStart.classList.toggle('subtitle-pending-action', isSubtitleLoading);
    updateStartCreditTooltip();
    updateAudioSummaryActions();
  }

  function updateStartButtonSuccess() {
    els.btnStart.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>' + t('summarizeAgain');
    els.btnStart.disabled = !historyLoaded || isSubtitleLoading || isProcessing || isAudioTranscribing
      || isBilibiliLoginRequiredForSubtitle() || Boolean(summaryInputBlockMessage());
    els.btnStart.classList.toggle('subtitle-pending-action', isSubtitleLoading);
    updateStartCreditTooltip();
    updateAudioSummaryActions();
  }

  function formatAudioTranscriptForDisplay(text) {
    return String(text || '')
      .replace(/\r\n?/g, '\n')
      .replace(/([。！？!?；;])(?=[^\n])/g, '$1\n')
      .replace(/\n{3,}/g, '\n\n')
      .trim();
  }

  function renderSubtitleText(text, { follow = false } = {}) {
    els.result.classList.remove('hidden');
    const sourceText = lastSubtitleWasTranscribedAudio
      ? formatAudioTranscriptForDisplay(text)
      : text;
    const displayText = toDisplayLanguage(sourceText);
    // A transcript may have thousands of lines. Creating a <br> element for
    // every line makes layout and mouse scrolling expensive; one text node plus
    // CSS newline handling is substantially cheaper.
    const cacheKey = [videoData?.bvid || '', videoData?.cid || '', currentLanguage, subtitleSegments.length].join('|');
    const canReuse = subtitleViewCache?.key === cacheKey && subtitleViewCache.sourceText === text && subtitleViewCache.mode === 'segments';
    els.subtitleView.textContent = '';
    stopSubtitleProgressiveRender();
    if (!lastSubtitleWasTranscribedAudio && subtitleSegments.length) {
      if (canReuse) {
        els.subtitleView.append(...subtitleViewCache.nodes);
        els.subtitleView.querySelector('.subtitle-segment.active')?.classList.remove('active');
      } else {
        const fragment = document.createDocumentFragment();
        subtitleRenderedCount = appendSubtitleSegmentBatch(fragment, 0, SUBTITLE_BATCH_CHARS);
        subtitleViewCache = { key: cacheKey, sourceText: text, mode: 'segments', nodes: [...fragment.childNodes] };
        els.subtitleView.appendChild(fragment);
      }
      scheduleSubtitleProgressiveRender();
    } else {
      subtitleViewCache = null;
      renderStaticSubtitleBlocks(displayText);
    }
    updateResultTabs('subtitle');
    renderFollowUpDock();
    applySubtitleSearchState();
    if (follow && !lastSubtitleWasTranscribedAudio) {
      void (async () => {
        // 历史恢复的字幕没有时间轴段时，若当前页恰是该视频则取实时字幕段
        // 恢复点击跳转与跟随滚动；否则保持静态段样式且不跟随滚动。
        if (!(await isVideoPlayingOnCurrentTab())) return;
        if (!subtitleSegments.length) {
          if (!(await fetchLiveSubtitleSegments())) return;
          if (currentResultMode !== 'subtitle') return;
          renderSubtitleText(lastSubtitleText, { follow: true });
          return;
        }
        startSubtitleFollow();
      })();
    }
  }

  // 历史恢复且不在原视频页时没有时间轴段，逐行按与真实字幕段一致的
  // 视觉样式渲染，避免出现无分段间距的密集纯文本。同样分批追加，
  // 避免长文本一次性创建大量节点卡顿。
  function renderStaticSubtitleBlocks(text) {
    subtitleStaticLines = String(text || '')
      .split('\n')
      .map((line) => line.trim())
      .filter(Boolean);
    const fragment = document.createDocumentFragment();
    let chars = 0;
    let index = 0;
    const total = subtitleStaticLines.length;
    while (index < total) {
      chars += subtitleStaticLines[index].length;
      if (index > 0 && chars > SUBTITLE_BATCH_CHARS) break;
      const block = document.createElement('div');
      block.className = 'subtitle-segment subtitle-segment-static';
      block.dataset.subtitleLine = String(index);
      block.textContent = subtitleStaticLines[index];
      fragment.appendChild(block);
      index++;
    }
    subtitleRenderedCount = index;
    els.subtitleView.appendChild(fragment);
    scheduleSubtitleProgressiveRender();
  }

  // ============ 字幕渐进渲染（性能优化） ============
  // 长字幕（上万行/几万字）一次性创建全部 DOM 节点会让侧栏明显卡顿。
  // 改为：首屏只渲染前 SUBTITLE_BATCH_CHARS 字，之后每隔几秒追加一批，
  // 直到全部渲染完。所有状态都挂在当前视频的身份串上，视频来回切换时
  // 旧视频的未完成批次一律作废，不会污染新视频的内容。
  function stopSubtitleProgressiveRender() {
    if (subtitleProgressiveTimer) {
      clearTimeout(subtitleProgressiveTimer);
      subtitleProgressiveTimer = null;
    }
  }

  function subtitleRenderContextKey() {
    const segmentsMode = !lastSubtitleWasTranscribedAudio && subtitleSegments.length > 0;
    const key = [videoData?.bvid || '', videoData?.cid || '', currentLanguage, subtitleSegments.length].join('|');
    return (segmentsMode ? 'seg|' : 'st|') + key;
  }

  function appendSubtitleSegmentBatch(fragment, startIndex, maxChars) {
    let chars = 0;
    let index = startIndex;
    const total = subtitleSegments.length;
    while (index < total) {
      chars += String(subtitleSegments[index].content || '').length;
      if (index > startIndex && chars > maxChars) break;
      const button = document.createElement('button');
      button.type = 'button';
      button.className = 'subtitle-segment';
      button.dataset.subtitleIndex = String(index);
      button.textContent = toDisplayLanguage(subtitleSegments[index].content);
      button.title = '跳转到 ' + Math.floor(subtitleSegments[index].from) + ' 秒';
      fragment.appendChild(button);
      index++;
    }
    return index;
  }

  function appendStaticSubtitleBatch(fragment, startIndex, maxChars) {
    let chars = 0;
    let index = startIndex;
    const total = subtitleStaticLines.length;
    while (index < total) {
      chars += subtitleStaticLines[index].length;
      if (index > startIndex && chars > maxChars) break;
      const block = document.createElement('div');
      block.className = 'subtitle-segment subtitle-segment-static';
      block.dataset.subtitleLine = String(index);
      block.textContent = subtitleStaticLines[index];
      fragment.appendChild(block);
      index++;
    }
    return index;
  }

  function scheduleSubtitleProgressiveRender() {
    stopSubtitleProgressiveRender();
    const segmentsMode = !lastSubtitleWasTranscribedAudio && subtitleSegments.length > 0;
    const total = segmentsMode ? subtitleSegments.length : subtitleStaticLines.length;
    if (!total || subtitleRenderedCount >= total) return;
    const contextKey = subtitleRenderContextKey();
    subtitleProgressiveTimer = setTimeout(() => {
      subtitleProgressiveTimer = null;
      if (subtitleRenderContextKey() !== contextKey) return;
      // 切到总结页签时暂停，等切回字幕页签再继续追加
      if (currentResultMode !== 'subtitle') {
        scheduleSubtitleProgressiveRender();
        return;
      }
      const fragment = document.createDocumentFragment();
      if (segmentsMode) {
        subtitleRenderedCount = appendSubtitleSegmentBatch(fragment, subtitleRenderedCount, SUBTITLE_BATCH_CHARS);
        if (subtitleViewCache) subtitleViewCache.nodes.push(...fragment.childNodes);
      } else {
        subtitleRenderedCount = appendStaticSubtitleBatch(fragment, subtitleRenderedCount, SUBTITLE_BATCH_CHARS);
      }
      els.subtitleView.appendChild(fragment);
      scheduleSubtitleProgressiveRender();
    }, SUBTITLE_BATCH_INTERVAL_MS);
  }

  function isVideoPlayingOnCurrentTab() {
    if (!currentTab?.id || !videoData?.bvid) return Promise.resolve(false);
    return chrome.tabs.sendMessage(currentTab.id, { action: 'getVideoInfo' })
      .then((live) => Boolean(
        live?.success && live.data.bvid === videoData.bvid
        && String(live.data.cid || '') === String(videoData.cid || '')
      ))
      .catch(() => false);
  }

  async function fetchLiveSubtitleSegments() {
    if (!videoData?.bvid || !currentTab?.id) return false;
    try {
      const r = await chrome.tabs.sendMessage(currentTab.id, { action: 'getSubtitleText', bvid: videoData.bvid, cid: videoData.cid });
      if (!r?.success || r.data?.bvid !== videoData.bvid || String(r.data?.cid || '') !== String(videoData.cid || '')) return false;
      if (!Array.isArray(r.data.segments) || !r.data.segments.length) return false;
      subtitleSegments = r.data.segments.map((segment) => ({
        from: Number(segment.from || 0),
        to: Number(segment.to || 0),
        content: String(segment.content || ''),
      }));
      lastSubtitleText = r.data.fullText || lastSubtitleText;
      lastSubtitleWasTranscribedAudio = false;
      return true;
    } catch (_) {
      return false;
    }
  }

  function stopSubtitleFollow() {
    subtitleFollowEnabled = false;
    if (subtitleFollowTimer) clearInterval(subtitleFollowTimer);
    subtitleFollowTimer = null;
  }

  function scrollSubtitleToCurrentTime(time) {
    let low = 0;
    let high = subtitleSegments.length - 1;
    let index = -1;
    while (low <= high) {
      const middle = (low + high) >> 1;
      const segment = subtitleSegments[middle];
      if (time < Number(segment.from || 0)) high = middle - 1;
      else if (time >= Number(segment.to || Infinity)) low = middle + 1;
      else { index = middle; break; }
    }
    if (index < 0) return;
    const current = els.resultContent.querySelector('[data-subtitle-index="' + index + '"]');
    if (!current || current.classList.contains('active')) return;
    els.resultContent.querySelector('.subtitle-segment.active')?.classList.remove('active');
    current.classList.add('active');
    const previous = els.resultContent.querySelector('[data-subtitle-index="' + Math.max(0, index - 2) + '"]');
    subtitleProgrammaticScroll = true;
    els.subtitleView.scrollTop = Math.max(0, (previous || current).offsetTop - els.subtitleView.offsetTop);
    requestAnimationFrame(() => { subtitleProgrammaticScroll = false; });
  }

  function startSubtitleFollow() {
    stopSubtitleFollow();
    if (subtitleSearchKeyword.trim()) return;
    subtitleFollowEnabled = true;
    const sync = async () => {
      if (!subtitleFollowEnabled || currentResultMode !== 'subtitle' || !currentTab?.id) return;
      try {
        const state = await chrome.tabs.sendMessage(currentTab.id, { action: 'getVideoPlaybackState' });
        if (state?.success) scrollSubtitleToCurrentTime(state.currentTime);
      } catch (_) {}
    };
    void sync();
    subtitleFollowTimer = setInterval(sync, 500);
  }

  // 定位到当前正在播放的字幕：读取播放器进度并滚动字幕列表到对应句。
  async function locateCurrentSubtitle() {
    if (!currentTab?.id || !videoData?.bvid) return;
    if (!(await isVideoPlayingOnCurrentTab())) return;
    if (!subtitleSegments.length) {
      if (!(await fetchLiveSubtitleSegments())) {
        showToast(t('subtitleLocateNoTimeline'), 'info');
        return;
      }
      if (currentResultMode !== 'subtitle') return;
      renderSubtitleText(lastSubtitleText, { follow: false });
    }
    try {
      const state = await chrome.tabs.sendMessage(currentTab.id, { action: 'getVideoPlaybackState' });
      if (state?.success) scrollSubtitleToCurrentTime(state.currentTime);
    } catch (_) {}
  }

  // ============ 字幕关键字搜索 ============
  function formatSubtitleTime(seconds) {
    const total = Math.max(0, Math.floor(Number(seconds) || 0));
    const mm = String(Math.floor(total / 60)).padStart(2, '0');
    const ss = String(total % 60).padStart(2, '0');
    return mm + ':' + ss;
  }

  // 有真实时间轴段时按段搜索；音频转写/历史恢复等无段数据时按行搜索。
  function subtitleSearchCorpus() {
    if (subtitleSegments.length) {
      return subtitleSegments.map((segment, index) => ({
        index,
        from: Number(segment.from || 0),
        to: Number(segment.to || 0),
        display: toDisplayLanguage(String(segment.content || '')),
      }));
    }
    const sourceText = lastSubtitleWasTranscribedAudio
      ? formatAudioTranscriptForDisplay(lastSubtitleText)
      : String(lastSubtitleText || '');
    return sourceText
      .split('\n')
      .map((line) => line.trim())
      .filter(Boolean)
      .map((line, index) => ({ index, from: null, to: null, display: toDisplayLanguage(line) }));
  }

  function runSubtitleSearch(keyword) {
    const query = String(keyword || '').trim().toLowerCase();
    if (!query) return [];
    return subtitleSearchCorpus().filter((entry) => entry.display.toLowerCase().includes(query));
  }

  function scrollSubtitleElementIntoView(element) {
    if (!element) return;
    const resultsBlock = els.subtitleView.querySelector('.subtitle-search-results');
    const blockHeight = resultsBlock ? resultsBlock.offsetHeight : 0;
    subtitleProgrammaticScroll = true;
    els.subtitleView.scrollTop = Math.max(0, element.offsetTop - els.subtitleView.offsetTop - blockHeight - 4);
    requestAnimationFrame(() => { subtitleProgrammaticScroll = false; });
  }

  // 把搜索结果行常驻显示在字幕列表顶部（即跟随滚动时当前字幕常驻的位置）。
  function renderSubtitleSearchResults() {
    const container = els.subtitleView;
    container.querySelector('.subtitle-search-results')?.remove();
    const keyword = subtitleSearchKeyword.trim();
    if (!keyword || currentResultMode !== 'subtitle' || !lastSubtitleText) {
      subtitleSearchMatches = [];
      return;
    }
    subtitleSearchMatches = runSubtitleSearch(keyword);
    const results = document.createElement('div');
    results.className = 'subtitle-search-results';
    const head = document.createElement('div');
    head.className = 'subtitle-search-results-head';
    const countLabel = document.createElement('span');
    countLabel.textContent = subtitleSearchMatches.length
      ? tf('subtitleSearchMatchCount', { count: subtitleSearchMatches.length })
      : t('subtitleSearchNoMatch');
    head.appendChild(countLabel);
    const clear = document.createElement('button');
    clear.type = 'button';
    clear.className = 'subtitle-search-results-clear';
    clear.textContent = t('subtitleSearchClear');
    clear.addEventListener('click', () => {
      if (els.subtitleSearchInput) els.subtitleSearchInput.value = '';
      handleSubtitleSearchInput();
    });
    head.appendChild(clear);
    results.appendChild(head);
    if (subtitleSearchMatches.length) {
      const fragment = document.createDocumentFragment();
      subtitleSearchMatches.forEach((match, rowIndex) => {
        const button = document.createElement('button');
        button.type = 'button';
        button.className = 'subtitle-search-match';
        button.dataset.matchIndex = String(rowIndex);
        button.title = match.from != null
          ? tf('subtitleSearchJump', { time: formatSubtitleTime(match.from) })
          : '';
        const time = document.createElement('span');
        time.className = 'subtitle-search-match-time';
        time.textContent = match.from != null ? formatSubtitleTime(match.from) : '';
        const text = document.createElement('span');
        text.className = 'subtitle-search-match-text';
        const at = match.display.toLowerCase().indexOf(keyword.toLowerCase());
        if (at >= 0) {
          text.append(document.createTextNode(match.display.slice(0, at)));
          const mark = document.createElement('mark');
          mark.textContent = match.display.slice(at, at + keyword.length);
          text.appendChild(mark);
          text.append(document.createTextNode(match.display.slice(at + keyword.length)));
        } else {
          text.textContent = match.display;
        }
        button.append(time, text);
        fragment.appendChild(button);
      });
      results.appendChild(fragment);
    } else {
      const empty = document.createElement('div');
      empty.className = 'subtitle-search-empty';
      empty.textContent = t('subtitleSearchNoMatch');
      results.appendChild(empty);
    }
    container.insertBefore(results, container.firstChild);
    if (subtitleSearchMatches.length) jumpToSubtitleSearchMatch(0, true, false);
  }

  function jumpToSubtitleSearchMatch(rowIndex, scroll, seek) {
    const match = subtitleSearchMatches[Number(rowIndex)];
    if (!match) return;
    subtitleSearchActiveIndex = Number(rowIndex);
    els.resultContent.querySelectorAll('.subtitle-search-match').forEach((el, index) => {
      el.classList.toggle('active', index === rowIndex);
    });
    els.resultContent.querySelector('.subtitle-segment.active')?.classList.remove('active');
    const segmentEl = els.resultContent.querySelector('[data-subtitle-index="' + match.index + '"]');
    if (segmentEl) {
      segmentEl.classList.add('active');
      if (scroll) scrollSubtitleElementIntoView(segmentEl);
      if (seek && match.from != null && currentTab?.id) {
        chrome.tabs.sendMessage(currentTab.id, { action: 'seekVideo', time: match.from }).catch(() => {});
      }
      return;
    }
    const lineEl = els.resultContent.querySelector('[data-subtitle-line="' + match.index + '"]');
    if (lineEl) {
      lineEl.classList.add('active');
      if (scroll) scrollSubtitleElementIntoView(lineEl);
    }
  }

  function handleSubtitleSearchInput() {
    const keyword = els.subtitleSearchInput?.value || '';
    subtitleSearchKeyword = keyword;
    if (keyword.trim()) {
      if (subtitleFollowEnabled) {
        subtitleSearchFollowPaused = true;
        stopSubtitleFollow();
      }
    } else if (subtitleSearchFollowPaused) {
      subtitleSearchFollowPaused = false;
      if (currentResultMode === 'subtitle' && !isSubtitleLoading && !isAudioTranscribing) {
        void (async () => {
          if (await isVideoPlayingOnCurrentTab()) startSubtitleFollow();
        })();
      }
    }
    renderSubtitleSearchResults();
  }

  function setSubtitleSearchOpen(open) {
    subtitleSearchOpen = Boolean(open);
    if (els.subtitleSearchBtn) els.subtitleSearchBtn.setAttribute('aria-expanded', String(subtitleSearchOpen));
    if (els.subtitleSearchBar) els.subtitleSearchBar.classList.toggle('hidden', !subtitleSearchOpen);
    if (subtitleSearchOpen) {
      els.subtitleSearchInput?.focus();
      handleSubtitleSearchInput();
    } else {
      closeSubtitleSearch();
    }
  }

  function closeSubtitleSearch() {
    subtitleSearchOpen = false;
    subtitleSearchKeyword = '';
    subtitleSearchMatches = [];
    if (els.subtitleSearchBar) els.subtitleSearchBar.classList.add('hidden');
    if (els.subtitleSearchInput) els.subtitleSearchInput.value = '';
    els.resultContent.querySelector('.subtitle-search-results')?.remove();
    els.resultContent.querySelector('.subtitle-segment.active')?.classList.remove('active');
    if (subtitleSearchFollowPaused) {
      subtitleSearchFollowPaused = false;
      if (currentResultMode === 'subtitle' && !isSubtitleLoading && !isAudioTranscribing) {
        void (async () => {
          if (await isVideoPlayingOnCurrentTab()) startSubtitleFollow();
        })();
      }
    }
  }

  // 字幕内容重渲染（渲染字幕/音频转写/历史恢复/切回字幕）后重新套用搜索状态。
  function applySubtitleSearchState() {
    const hasSubtitle = Boolean(lastSubtitleText) && currentResultMode === 'subtitle' && !els.result.classList.contains('hidden');
    if (els.subtitleSearchBtn) {
      els.subtitleSearchBtn.classList.toggle('hidden', !hasSubtitle);
      els.subtitleSearchBtn.title = t('subtitleSearch');
      els.subtitleSearchBtn.setAttribute('aria-label', t('subtitleSearch'));
    }
    if (els.subtitleLocateBtn) {
      els.subtitleLocateBtn.classList.toggle('hidden', !hasSubtitle);
      els.subtitleLocateBtn.title = t('subtitleLocateTip');
      els.subtitleLocateBtn.setAttribute('aria-label', t('subtitleLocate'));
    }
    if (!hasSubtitle || !subtitleSearchOpen) {
      els.resultContent.querySelector('.subtitle-search-results')?.remove();
      return;
    }
    renderSubtitleSearchResults();
  }

  function normalizeResultFontLevel(value) {
    const level = Number.parseInt(value, 10);
    return Number.isFinite(level) ? Math.min(RESULT_FONT_LEVEL_MAX, Math.max(RESULT_FONT_LEVEL_MIN, level)) : 0;
  }

  function updateResultFontControls() {
    const smallerLabel = currentLanguage === 'en' ? 'Decrease transcript and summary font size' : (currentLanguage === 'zh-TW' ? '縮小字幕和摘要字號' : '缩小字幕和总结字号');
    const largerLabel = currentLanguage === 'en' ? 'Increase transcript and summary font size' : (currentLanguage === 'zh-TW' ? '放大字幕和摘要字號' : '放大字幕和总结字号');
    if (els.resultFontSmallerBtn) {
      els.resultFontSmallerBtn.title = smallerLabel;
      els.resultFontSmallerBtn.setAttribute('aria-label', smallerLabel);
      els.resultFontSmallerBtn.disabled = resultFontLevel <= RESULT_FONT_LEVEL_MIN;
    }
    if (els.resultFontLargerBtn) {
      els.resultFontLargerBtn.title = largerLabel;
      els.resultFontLargerBtn.setAttribute('aria-label', largerLabel);
      els.resultFontLargerBtn.disabled = resultFontLevel >= RESULT_FONT_LEVEL_MAX;
    }
  }

  function applyResultFontLevel(value) {
    resultFontLevel = normalizeResultFontLevel(value);
    els.resultContent?.style.setProperty('--result-font-size', `${RESULT_FONT_BASE_SIZE_PX + resultFontLevel}px`);
    updateResultFontControls();
  }

  function changeResultFontLevel(delta) {
    const nextLevel = normalizeResultFontLevel(resultFontLevel + delta);
    if (nextLevel === resultFontLevel) return;
    applyResultFontLevel(nextLevel);
    void chrome.storage.local.set({ [RESULT_FONT_LEVEL_KEY]: nextLevel }).catch(() => {});
  }

  function updateResultTabs(mode) {
    if (mode !== 'subtitle') stopSubtitleFollow();
    currentResultMode = mode;
    // 字幕/总结视图常驻 DOM，切换页签只切换可见性，不销毁重建节点
    els.subtitleView?.classList.toggle('hidden', mode !== 'subtitle');
    els.summaryView?.classList.toggle('hidden', mode !== 'summary');
    if (mode !== 'subtitle' && subtitleSearchOpen) closeSubtitleSearch();
    if (mode !== 'subtitle' && els.subtitleSearchBtn) els.subtitleSearchBtn.classList.add('hidden');
    if (mode !== 'subtitle' && els.subtitleLocateBtn) els.subtitleLocateBtn.classList.add('hidden');
    els.resultFontSmallerBtn?.classList.toggle('hidden', mode !== 'summary');
    els.resultFontLargerBtn?.classList.toggle('hidden', mode !== 'summary');
    var tabSummary = document.getElementById('tab-summary');
    var tabSubtitle = document.getElementById('tab-subtitle');
    if (tabSummary) {
      tabSummary.classList.toggle('active', mode === 'summary');
      tabSummary.setAttribute('aria-selected', mode === 'summary' ? 'true' : 'false');
    }
    if (tabSubtitle) {
      tabSubtitle.classList.toggle('active', mode === 'subtitle');
      tabSubtitle.setAttribute('aria-selected', mode === 'subtitle' ? 'true' : 'false');
    }
    if (els.resultTabs) {
      els.resultTabs.classList.toggle('summary-active', mode === 'summary');
      els.resultTabs.classList.toggle('subtitle-active', mode === 'subtitle');
    }
    updateSummaryCommentButton();
  }

  function updateSummaryCommentButton() {
    els.btnShareComment?.classList.toggle('hidden', !getShareableSummary() || isProcessing);
  }

  function getShareableSummary() {
    if (!videoData?.bvid) return '';
    if (lastSummary.trim() && summaryResultMeta?.bvid === videoData.bvid && String(summaryResultMeta?.cid || '') === String(videoData.cid || '')) {
      return lastSummary.trim();
    }
    return summaryHistory.find((record) => (
      record.videoData?.bvid === videoData.bvid
      && String(record.videoData?.cid || '') === String(videoData.cid || '')
      && record.lastSummary?.trim()
    ))?.lastSummary?.trim() || '';
  }

  async function reportVideoStats({ subtitleText = '' } = {}) {
    if (!videoData?.bvid) return;
    const reportKey = subtitleText
      ? [videoData.bvid, videoData.cid, subtitleText.length].join(':')
      : '';
    // Opening the panel and starting a summary used to report the same large
    // transcript twice, including a second whitespace scan and message copy.
    if (reportKey && reportKey === lastReportedSubtitleKey) return;
    if (reportKey) lastReportedSubtitleKey = reportKey;
    try {
      await chrome.runtime.sendMessage({
        action: 'reportVideoWatchStats',
        stats: {
          bvid: videoData.bvid,
          cid: videoData.cid || '',
          title: learningRecordTitle(),
          owner: videoData.owner || '',
          duration: videoData.duration || 0,
          subtitle_chars: String(subtitleText || '').replace(/\s+/g, '').length,
        },
      });
    } catch (err) {
      console.warn('[BiliSummary] 学习统计上报失败:', err.message);
    }
  }

  // 加载字幕 —— 带完整重试逻辑
  async function loadSubtitleDirect({ maxAttempts = 1, detectionId = videoDetectionId } = {}) {
    if (!videoData || !currentTab) return;
    const sourceTabId = currentTab.id;
    const sourceBvid = videoData.bvid;
    const sourceCid = videoData.cid;
    let lastResponse = null;
    let lastError = null;
    let contentScriptReinjected = false;
    const visibleAttempts = Math.min(2, maxAttempts);
    let notFoundShown = false;

    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
      if (detectionId !== videoDetectionId || isAudioTranscribing) return;
      if (attempt > 1) {
        const retryDelays = [450, 800, 1000, 1200];
        await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt - 2] || 1200));
      }
      if (detectionId !== videoDetectionId || isAudioTranscribing) return;

      try {
        const r = await chrome.tabs.sendMessage(sourceTabId, {
          action: 'getSubtitleText', bvid: sourceBvid, cid: sourceCid,
        });
        if (detectionId !== videoDetectionId || isAudioTranscribing) return;
        lastResponse = r;
        if (r?.success && r.data?.fullText) {
          ensureSubtitleMatchesCurrent(r);
          if (detectionId !== videoDetectionId) return;
          videoData.hasSubtitle = true;
          pendingFastAsrConfirmationKey = '';
          videoData.subtitleInfo = {
            lan_doc: r.data.subtitleLan || videoData.subtitleInfo?.lan_doc || '',
            url: r.data.subtitleUrl || videoData.subtitleInfo?.url || '',
          };
          lastSubtitleText = r.data.fullText;
          subtitleSegments = Array.isArray(r.data.segments) ? r.data.segments : [];
          isSubtitleLoading = false;
          lastSubtitleWasTranscribedAudio = false;
          subtitleResultMeta = createResultMetadata();
          updateEstimatedSummaryCredits(lastSubtitleText);
          reportVideoStats({ subtitleText: lastSubtitleText });
          renderSubtitleText(lastSubtitleText, { follow: true });
          renderFollowUpDock();
          els.statusBar.classList.add('hidden');
          showToast(`✅ 字幕已加载 (${r.data.subtitleLan || '未知'})`, 'success');
          return;
        }
        lastError = new Error(r?.error || '字幕暂未就绪');
      } catch (e) {
        lastError = e;
        if (!contentScriptReinjected) {
          contentScriptReinjected = true;
          try {
            if (detectionId === videoDetectionId) await chrome.runtime.sendMessage({ action: 'injectContentScript', tabId: sourceTabId });
          } catch (injectError) {}
        }
      }

      // 两次前台尝试后先给出稳定结果；剩余尝试只在后台静默进行，
      // 以覆盖 B 站 AI 字幕稍后才就绪的情况。
      if (attempt === visibleAttempts && !notFoundShown && detectionId === videoDetectionId && !isAudioTranscribing) {
        videoData.hasSubtitle = false;
        isSubtitleLoading = false;
        notFoundShown = true;
        showSubtitleNotFound({ force: true });
        els.statusBar.classList.add('hidden');
      }
    }

    if (detectionId !== videoDetectionId || isAudioTranscribing) return;
    const finalMessage = lastResponse?.error || lastError?.message || '未知错误';
    isSubtitleLoading = false;
    videoData.hasSubtitle = false;
    if (notFoundShown || isSubtitleNotFoundMessage(finalMessage)) {
      showSubtitleNotFound({ force: true });
      els.statusBar.classList.add('hidden');
    } else {
      els.subtitleView.textContent = '字幕加载失败: ' + finalMessage;
      setStatus('error', '❌', '字幕加载失败，请点击刷新重试');
    }
  }

  function isSubtitleNotFoundMessage(message) {
    return /没有可用字幕|未找到.*字幕|字幕内容为空|该视频没有字幕|无字幕/.test(String(message || ''));
  }

  function showSubtitleNotFound(options = {}) {
    if (isAudioTranscribing || (isSubtitleLoading && !options.force)) return;
    isSubtitleLoading = false;
    lastSubtitleText = '';
    lastSubtitleWasTranscribedAudio = false;
    subtitleResultMeta = null;
    estimatedSummaryCredits = 0;
    els.btnStart.disabled = false;
    updateStartButtonIdle();
    els.result.classList.remove('hidden');
    updateResultTabs('subtitle');
    els.subtitleView.textContent = isBilibiliLoginRequiredForSubtitle()
      ? '未找到字幕：当前网页未登录B站，登录后才能获取账号可见的字幕。'
      : '本视频没有可用字幕；如有文字，可能是嵌在画面里的。可以尝试音频转写总结。';
    renderFollowUpDock();
    applySubtitleSearchState();
    updateStudyActionState();
  }

  function settleSubtitleNotFoundIfStuck(detectionId) {
    if (detectionId !== videoDetectionId || isAudioTranscribing || lastSubtitleText.trim()) return;
    if (videoData?.hasSubtitle !== false || els.subtitleView?.textContent.trim() !== '正在加载字幕...') return;
    showSubtitleNotFound({ force: true });
  }

  function compareVersions(left, right) {
    const a = String(left || '').split('.').map((part) => Number(part) || 0);
    const b = String(right || '').split('.').map((part) => Number(part) || 0);
    for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
      const difference = (a[index] || 0) - (b[index] || 0);
      if (difference) return difference > 0 ? 1 : -1;
    }
    return 0;
  }

  function hideAdvancedNotice() {
    activeAdvancedNotice = null;
    els.advancedNotice.classList.add('hidden');
    els.advancedNoticeAction.classList.add('hidden');
  }

  function renderAdvancedNotice(notice, source = 'server') {
    if (!notice || advancedNoticeClosedSources.has(source)) return;
    activeAdvancedNotice = { ...notice, source };
    els.advancedNoticeIcon.textContent = notice.icon || '🔔';
    els.advancedNoticeTitle.textContent = String(notice.title || '重要通知');
    els.advancedNoticeContent.textContent = String(notice.content || '');
    els.advancedNoticeAction.textContent = String(notice.actionLabel || '');
    els.advancedNoticeAction.classList.toggle('hidden', !notice.actionLabel);
    els.advancedNotice.classList.remove('hidden');
  }

  async function refreshAdvancedNotice() {
    const [result, session, stored] = await Promise.all([
      chrome.runtime.sendMessage({ action: 'serverAdvancedNotice' }).catch(() => null),
      chrome.runtime.sendMessage({ action: 'serverMe' }).catch(() => null),
      chrome.storage.local.get(ADVANCED_NOTICE_DISMISSED_KEY).catch(() => ({})),
    ]);
    serverAdvancedNotice = result?.success ? result.notice : null;
    if (serverAdvancedNotice?.hideAtVersion
        && compareVersions(chrome.runtime.getManifest().version, serverAdvancedNotice.hideAtVersion) >= 0) {
      serverAdvancedNotice = null;
    }
    const permanentlyDismissed = serverAdvancedNotice?.repeatOnOpen === false
      && stored[ADVANCED_NOTICE_DISMISSED_KEY] === serverAdvancedNotice.revision;
    if (serverAdvancedNotice && !permanentlyDismissed && !advancedNoticeClosedSources.has('server')) {
      renderAdvancedNotice({ ...serverAdvancedNotice, baseUrl: result.baseUrl }, 'server');
    } else if (!session?.success && !advancedNoticeClosedSources.has('login')) {
      renderAdvancedNotice({
        icon: '🔐', title: '未登录', content: '登录账户即可使用总结能力',
        actionLabel: '前往登录', actionType: 'login',
      }, 'login');
    } else {
      hideAdvancedNotice();
    }
  }

  function showLocalAdvancedNotice(notice, source) {
    renderAdvancedNotice(notice, source);
  }

  async function dismissAdvancedNotice() {
    const notice = activeAdvancedNotice;
    if (!notice) return;
    advancedNoticeClosedSources.add(notice.source);
    if (notice.source === 'server' && notice.repeatOnOpen === false && notice.revision) {
      await chrome.storage.local.set({ [ADVANCED_NOTICE_DISMISSED_KEY]: notice.revision }).catch(() => {});
    }
    hideAdvancedNotice();
  }

  function openAdvancedNoticeAction() {
    const notice = activeAdvancedNotice;
    if (!notice) return;
    if (notice.actionType === 'login') {
      openDashboardNextToCurrent().catch((err) => showToast('打开登录失败：' + err.message, 'error'));
      return;
    }
    if (notice.actionType === 'bilibili-login') {
      chrome.tabs.create({ url: 'https://passport.bilibili.com/login' });
      return;
    }
    const rawUrl = String(notice.actionUrl || '');
    if (!rawUrl) return;
    const url = rawUrl.startsWith('/') ? String(notice.baseUrl || '').replace(/\/+$/, '') + rawUrl : rawUrl;
    if (/^https:\/\//i.test(url)) chrome.tabs.create({ url });
  }

  async function refreshNotificationBadge() {
    if (!els.btnServer || !els.notificationBadge || !els.notificationTip) return;
    try {
      const result = await chrome.runtime.sendMessage({ action: 'serverNotifications' });
      setNotificationBadge(result?.success ? result.unreadCount : 0);
    } catch (_) {
      els.notificationBadge.classList.add('hidden');
      els.btnServer.classList.remove('has-notifications');
      els.emptyNotificationBadge?.classList.add('hidden');
      els.btnEmptyServer?.classList.remove('has-notifications');
    }
  }

  function setNotificationBadge(value) {
    const count = Math.max(0, Number(value || 0));
    els.notificationBadge.textContent = count > 99 ? '99+' : String(count);
    els.notificationBadge.classList.toggle('hidden', count <= 0);
    els.notificationTip.textContent = count > 0 ? `您有${count}条通知` : '';
    els.btnServer.classList.toggle('has-notifications', count > 0);
    els.btnServer.setAttribute('aria-describedby', count > 0 ? 'notification-tip' : '');
    if (els.btnEmptyServer && els.emptyNotificationBadge && els.emptyNotificationTip) {
      els.emptyNotificationBadge.textContent = count > 99 ? '99+' : String(count);
      els.emptyNotificationBadge.classList.toggle('hidden', count <= 0);
      els.emptyNotificationTip.textContent = count > 0 ? `您有${count}条通知` : '';
      els.btnEmptyServer.classList.toggle('has-notifications', count > 0);
      els.btnEmptyServer.setAttribute('aria-describedby', count > 0 ? 'empty-notification-tip' : '');
    }
  }

  async function refreshAuthenticatedUi() {
    const session = await chrome.runtime.sendMessage({ action: 'serverMe' }).catch(() => null);
    if (!session?.success || !session.user) {
      await refreshAdvancedNotice();
      return false;
    }
    hideLoginRequiredPrompt();
    await chrome.storage.local.set({ user: session.user });
    await loadAccountSettings(session.user);
    await refreshMembershipAccess();
    await refreshNotificationBadge();
    await refreshAdvancedNotice();
    return true;
  }

  function showBilibiliLoginPrompt() {
    showLocalAdvancedNotice({
      icon: '🔐', title: 'B站未登录', content: '登录后才能获取账号可见的字幕',
      actionLabel: '前往登录', actionType: 'bilibili-login',
    }, 'bilibili-login');
  }

  function showSummaryLoading(text = t('generatingInSummary')) {
    els.progress.classList.add('hidden');
    els.result.classList.remove('hidden');
    updateResultTabs('summary');
    els.summaryView.textContent = '';
    const loading = document.createElement('div');
    loading.className = 'summary-generating-placeholder';
    loading.setAttribute('role', 'status');
    loading.setAttribute('aria-live', 'polite');
    loading.textContent = text;
    els.summaryView.appendChild(loading);
    els.summaryView.scrollTop = 0;
    renderFollowUpDock();
  }

  function showSummaryPending() {
    els.result.classList.remove('hidden');
    els.summaryView.textContent = '总结尚未生成';
    updateResultTabs('summary');
    renderFollowUpDock();
  }

  function showSubtitleLoading(text = t('transcribingAudio'), detail = '') {
    els.progress.classList.add('hidden');
    els.result.classList.remove('hidden');
    updateResultTabs('subtitle');
    els.subtitleView.textContent = '';
    const loading = document.createElement('div');
    loading.className = 'summary-generating-placeholder audio-transcription-loading';
    loading.setAttribute('role', 'status');
    loading.setAttribute('aria-live', 'polite');
    const main = document.createElement('div');
    main.className = 'audio-progress-main';
    main.textContent = text;
    loading.appendChild(main);
    if (detail) {
      const note = document.createElement('div');
      note.className = 'audio-progress-note';
      note.textContent = detail;
      loading.appendChild(note);
    }
    els.subtitleView.appendChild(loading);
    els.subtitleView.scrollTop = 0;
    renderFollowUpDock();
    applySubtitleSearchState();
  }

  function updateAudioTranscriptionLoading(message, detail = '') {
    if (!isAudioTranscribing || currentResultMode !== 'subtitle') return;
    const loading = els.resultContent.querySelector('.summary-generating-placeholder');
    if (!loading) return;
    let main = loading.querySelector('.audio-progress-main');
    if (!main) {
      loading.textContent = '';
      main = document.createElement('div');
      main.className = 'audio-progress-main';
      loading.appendChild(main);
    }
    main.textContent = message;
    let note = loading.querySelector('.audio-progress-note');
    if (detail && !note) {
      note = document.createElement('div');
      note.className = 'audio-progress-note';
      loading.appendChild(note);
    }
    if (note) {
      note.textContent = detail;
      note.classList.toggle('hidden', !detail);
    }
  }

  function showSubtitleFailure(text) {
    els.progress.classList.add('hidden');
    els.result.classList.remove('hidden');
    updateResultTabs('subtitle');
    els.subtitleView.textContent = text;
    renderFollowUpDock();
    applySubtitleSearchState();
  }

  async function revealAudioTranscript(text, session = activeVideoSession) {
    if (!isActiveSession(session)) return;
    const loading = els.resultContent.querySelector('.summary-generating-placeholder');
    if (loading) {
      loading.classList.add('is-leaving');
      await delay(240);
      if (!isActiveSession(session)) return;
    }
    const displayText = toDisplayLanguage(formatAudioTranscriptForDisplay(text));
    const chars = Array.from(displayText);
    const chunkSize = Math.max(1, Math.ceil(chars.length / 70));
    const fixedScrollTop = 0;
    let rendered = '';
    updateResultTabs('subtitle');
    els.subtitleView.textContent = '';
    for (let offset = 0; offset < chars.length; offset += chunkSize) {
      if (!isActiveSession(session)) return;
      rendered += chars.slice(offset, offset + chunkSize).join('');
      els.subtitleView.textContent = rendered;
      els.subtitleView.scrollTop = fixedScrollTop;
      if (offset + chunkSize < chars.length) await delay(20);
    }
    els.subtitleView.scrollTop = fixedScrollTop;
    applySubtitleSearchState();
  }

  async function fadeSummaryLoading() {
    const loading = els.resultContent.querySelector('.summary-generating-placeholder');
    if (!loading) return;
    loading.classList.add('is-leaving');
    await delay(240);
  }

  function renderStreamingSummary(markdown) {
    const previousScrollTop = els.summaryView.scrollTop;
    els.summaryView.innerHTML = renderMarkdown(toDisplayLanguage(markdown));
    if (isPaidMembershipTier(currentUserTier) && autoScrollStreamingSummary) {
      els.summaryView.scrollTop = els.summaryView.scrollHeight;
    } else {
      els.summaryView.scrollTop = previousScrollTop;
    }
  }

  function isRetryableAudioExtractionError(response) {
    const message = String(response?.error || '');
    return !response?.success && /音频(?:地址无效|提取失败|提取中断)|刷新视频页面|保持视频页面打开/.test(message);
  }

  async function refreshCurrentVideoRuntimeState(session = activeVideoSession) {
    // 新视频还没有生成总结时 activeVideoSession 可能为空；学习入口仍应使用
    // 当前 tab/videoData 做身份确认，而不是把“没有总结会话”误判成“无法确认视频”。
    const expectedVideo = session?.videoData || videoData;
    const targetTab = session?.tab || currentTab;
    if (!targetTab?.id || !expectedVideo?.bvid) return false;

    let fresh = null;
    for (let attempt = 0; attempt < 3; attempt += 1) {
      try {
        fresh = await chrome.tabs.sendMessage(targetTab.id, { action: 'getVideoInfo' });
      } catch (_) {
        fresh = null;
      }
      if (fresh?.success) break;
      if (attempt === 0) {
        try {
          await chrome.runtime.sendMessage({ action: 'injectContentScript', tabId: targetTab.id });
        } catch (_) {}
      }
      if (attempt < 2) await delay(350);
    }
    if (!fresh?.success || !fresh.data?.bvid || !fresh.data?.cid) return false;
    if (fresh.data.bvid !== expectedVideo.bvid || String(fresh.data.cid) !== String(expectedVideo.cid)) {
      throw new Error('视频页面已切换，请重新点击开始总结');
    }
    const mergedVideo = { ...expectedVideo, ...fresh.data };
    if (session) session.videoData = mergedVideo;
    if (targetTab.id === currentTab?.id) currentTab = { ...currentTab, ...targetTab };
    if (session && isActiveSession(session)) videoData = session.videoData;
    else if (!session) videoData = mergedVideo;
    return true;
  }

  async function fetchStudyAgentConfig() {
    try {
      const stored = await chrome.storage.local.get('serverBaseUrl');
      const response = await fetch(defaultServerBaseUrl(stored.serverBaseUrl) + '/api/agent/public-config', { cache: 'no-store' });
      const data = await response.json().catch(() => ({}));
      studyAgentConfig = { enabled: data.enabled === true, publicUrl: String(data.publicUrl || '').trim() };
    } catch (_) {
      studyAgentConfig = { enabled: false, publicUrl: '' };
    }
    if (els.btnOpenStudyAgent) {
      els.btnOpenStudyAgent.disabled = !studyAgentConfig.enabled || !studyAgentConfig.publicUrl;
      els.btnOpenStudyAgent.textContent = '获取我的学习助手';
      els.btnOpenStudyAgent.title = studyAgentConfig.enabled ? '' : '学习助手暂未开放';
    }
  }

  async function openStudyAgentWebsite() {
    if (!studyAgentConfig.enabled || !studyAgentConfig.publicUrl) return;
    const stored = await chrome.storage.local.get('serverBaseUrl');
    const baseUrl = defaultServerBaseUrl(stored.serverBaseUrl);
    let target;
    try { target = new URL(studyAgentConfig.publicUrl, baseUrl).toString(); } catch (_) { return; }
    await chrome.tabs.create({ url: target });
  }

  // 音频总结新协议：本地把 B站 AAC 流解码 → 重采样 16kHz 单声道 → WSOLA
  // 1.5x 保持音调压缩 → opus 24kbps
  // 编码 → Ogg 封装后再分块上传，体积比原始流小约 8~9 倍，绕开服务器 1~3Mbps 的
  // 入站带宽瓶颈（50MB 原始流约压到 5~6MB）。任何环节不可用或失败都返回 null，
  // 由调用方回退到 SW 原始流直传；服务端按版本化请求头避免再次执行 1.5x 变速。
  async function tryTranscribeAudioWithOpusUpload(session, asrMode, expectedRunId) {
    try {
      if (typeof AudioDecoder === 'undefined' || typeof AudioEncoder === 'undefined' || typeof OfflineAudioContext === 'undefined') return null;
      if (!session.videoData?.audioUrls?.length && !session.videoData?.audioUrl) return null;
      const support = await AudioEncoder.isConfigSupported({ codec: 'opus', sampleRate: 16000, numberOfChannels: 1, bitrate: 24000 }).catch(() => ({ supported: false }));
      if (!support?.supported) return null;
      const urls = [...new Set([...(Array.isArray(session.videoData.audioUrls) ? session.videoData.audioUrls : []), session.videoData.audioUrl]
        .map((url) => String(url || '').trim()).filter(Boolean))];
      if (!urls.length) return null;
      const statusUpdate = (message, percent) => {
        if (!isActiveSession(session)) return;
        session.audioProgressMessage = message;
        updateAudioTranscriptionLoading(message, session.audioProgressDetail);
      };
      // 命中既有转写缓存就直接返回，不重复提取/转码/上传（与 SW 旧路径的缓存预检一致）。
      try {
        const serverSession = await requireLoggedInSession();
        const statusUrl = `${defaultServerBaseUrl(serverSession.serverBaseUrl)}/api/asr/transcriptions/status?bvid=${encodeURIComponent(session.videoData.bvid)}&cid=${encodeURIComponent(String(session.videoData.cid || ''))}&mode=${asrMode}`;
        const cacheResponse = await fetch(statusUrl, { cache: 'no-store', headers: { Authorization: 'Bearer ' + serverSession.token } });
        if (cacheResponse.ok) {
          const cached = await cacheResponse.json().catch(() => null);
          if (cached?.status === 'completed' && cached.cached && cached.text) {
            return {
              success: true, text: cached.text,
              segments: Array.isArray(cached.segments) ? cached.segments : [],
              requestId: session.audioRequestId, bvid: session.videoData.bvid,
              cid: String(session.videoData.cid || ''),
            };
          }
        }
      } catch (_) {}
      statusUpdate('正在提取音频（0%）', 0);
      const { chunks, total } = await downloadBilibiliSegmentBytes({ urls }, {
        tabId: session.tab.id, cid: session.videoData.cid, epId: session.videoData.epId,
      }, session.videoData.bvid, 1024 * 1024,
        (loaded, all) => {
          if (!isActiveSession(session)) return;
          const percent = Math.min(100, Math.round((loaded / Math.max(1, all)) * 100));
          statusUpdate(`正在提取音频（${percent}%）`, percent);
        });
      if (expectedRunId !== session.runId || !isActiveSession(session)) return null;
      const ogg = await transcodeAudioToOggOpus(chunks, total, statusUpdate);
      if (!ogg || !ogg.byteLength) return null;
      const uploadId = crypto.randomUUID().replace(/-/g, '');
      const chunkSize = 512 * 1024;
      const chunkTotal = Math.max(1, Math.ceil(ogg.byteLength / chunkSize));
      let uploadedChunks = 0;
      const uploadChunk = async (index) => {
        if (expectedRunId !== session.runId) return null;
        const start = index * chunkSize;
        const part = ogg.slice(start, Math.min(ogg.byteLength, start + chunkSize));
        const response = await chrome.runtime.sendMessage({
          action: 'uploadTranscribedAudioChunk',
          uploadId, chunkIndex: index, chunkTotal, offset: start,
          bytes: encodeBase64Chunk(part),
          bvid: session.videoData.bvid, cid: session.videoData.cid,
          title: session.videoData.title, duration: session.videoData.duration,
          asrMode, audioMimeType: 'audio/ogg',
          audioSpeed: AUDIO_UPLOAD_SPEED, audioPreprocess: AUDIO_UPLOAD_PREPROCESS,
        });
        if (!response?.success) throw new Error(response?.error || '上传失败');
        if (!isActiveSession(session)) return null;
        uploadedChunks += 1;
        const percent = Math.min(100, Math.round((uploadedChunks / chunkTotal) * 100));
        statusUpdate(`正在上传压缩音频（${percent}%）`, percent);
        return response;
      };
      // 慢上行下单路上传吞吐很差；普通块用 4 个短请求并发，末块必须等它们全部
      // 成功后再发送，因为末块会触发服务端组装和异步 ASR。服务端按 chunkIndex
      // 落盘并支持乱序到达，旧扩展的串行上传协议不受影响。
      const ordinaryChunkCount = Math.max(0, chunkTotal - 1);
      let nextChunkIndex = 0;
      const uploadWorker = async () => {
        while (nextChunkIndex < ordinaryChunkCount) {
          const index = nextChunkIndex;
          nextChunkIndex += 1;
          await uploadChunk(index);
        }
      };
      await Promise.all(Array.from({ length: Math.min(4, ordinaryChunkCount) }, uploadWorker));
      if (chunkTotal > 0) {
        const finalResponse = await uploadChunk(chunkTotal - 1);
        if (!finalResponse) return null;
      }
      return {
        success: true, async: true, requestId: session.audioRequestId,
        bvid: session.videoData.bvid, cid: String(session.videoData.cid || ''),
      };
    } catch (_) {
      return null;
    }
  }

  async function transcribeCurrentVideoAudio(session, expectedRunId, asrMode = 'slow') {
    if (session.videoData?.bilibiliLoggedIn !== true) {
      if (isActiveSession(session)) showBilibiliLoginPrompt();
      return '';
    }
    if (!session.videoData?.audioUrl) {
      try {
        await refreshCurrentVideoRuntimeState(session);
      } catch (_) {}
      if (!session.videoData?.audioUrl) {
        if (isActiveSession(session)) {
          showSubtitleFailure('暂时未找到可提取的音频流，请重试');
          setStatus('error', '❌', '暂时未找到可提取的音频流，请重试');
        }
        return '';
      }
    }
    try {
      await requireLoggedInSession();
      session.audioProgressMessage = t('transcribingAudio');
      session.audioProgressDetail = t('audioUploadCaution');
      session.audioRequestId = crypto.randomUUID();
      session.audioSummaryJobId = '';
      if (isActiveSession(session)) showSubtitleLoading(session.audioProgressMessage, session.audioProgressDetail);
      let response = await tryTranscribeAudioWithOpusUpload(session, asrMode, expectedRunId);
      if (!response) {
        for (let attempt = 1; attempt <= 2; attempt += 1) {
        response = await chrome.runtime.sendMessage({
          action: 'transcribeVideoAudio',
          requestId: session.audioRequestId,
          audioUrl: session.videoData.audioUrl,
          audioUrls: session.videoData.audioUrls,
          audioMimeType: session.videoData.audioMimeType,
          tabId: session.tab.id,
          duration: session.videoData.duration,
          bvid: session.videoData.bvid,
          cid: session.videoData.cid,
          epId: session.videoData.epId,
          title: session.videoData.title,
          owner: session.videoData.owner,
          url: session.videoData.url || session.tab.url,
          pic: session.videoData.pic || '',
          pages: Array.isArray(session.videoData.pages) ? session.videoData.pages : [],
          stylePrompt: getStylePrompt(),
          language: currentLanguage,
          maxTokens: maxOutputTokens,
          asrMode,
        });
        if (expectedRunId !== session.runId) return '';
        if (response?.success || attempt === 2 || !isRetryableAudioExtractionError(response)) break;
        await delay(500);
        await refreshCurrentVideoRuntimeState(session);
        if (isActiveSession(session)) showSubtitleLoading(t('transcribingAudio'), t('audioUploadCaution'));
        }
      }
      if (!response?.success) throwResponseError(response, '音频转写失败');
      if (expectedRunId !== session.runId) return '';
      if (response.async === true) {
        // 异步协议：服务端已在后台受理转写（X-ASR-Async），侧栏在页面上下文
        // 直接轮询 status 端点取结果——扩展 Service Worker 有 5 分钟生命周期
        // 限制，长连接等待会被 Chrome 终止，页面轮询不受影响。
        const serverSession = await requireLoggedInSession();
        session.audioProgressDetail = t('audioUploadedSafe');
        if (isActiveSession(session)) updateAudioTranscriptionLoading(session.audioProgressMessage, session.audioProgressDetail);
        const statusUrl = `${defaultServerBaseUrl(serverSession.serverBaseUrl)}/api/asr/transcriptions/status?bvid=${encodeURIComponent(session.videoData.bvid)}&cid=${encodeURIComponent(String(session.videoData.cid || ''))}&mode=${asrMode}`;
        const ASYNC_ASR_POLL_TIMEOUT_MS = 25 * 60_000;
        const ASYNC_ASR_POLL_INTERVAL_MS = 2000;
        const pollStartedAt = Date.now();
        let consecutiveUnknownPolls = 0;
        let consecutiveUnavailablePolls = 0;
        while (Date.now() - pollStartedAt < ASYNC_ASR_POLL_TIMEOUT_MS) {
          if (expectedRunId !== session.runId) return '';
          let status = null;
          try {
            const pollResponse = await fetch(statusUrl, {
              cache: 'no-store',
              headers: { Authorization: 'Bearer ' + serverSession.token },
            });
            if (pollResponse.ok) status = await pollResponse.json().catch(() => null);
            consecutiveUnavailablePolls = status ? 0 : consecutiveUnavailablePolls + 1;
          } catch (_) {
            consecutiveUnavailablePolls += 1;
          }
          if (status?.status === 'completed' && status.cached && status.text) {
            response = {
              success: true,
              text: status.text,
              segments: Array.isArray(status.segments) ? status.segments : [],
              requestId: session.audioRequestId,
              bvid: session.videoData.bvid,
              cid: String(session.videoData.cid || ''),
            };
            break;
          }
          if (status?.status === 'failed') {
            const error = new Error(status.error || '音频转写失败，任务已停止');
            error.code = status.code || 'ASR_TRANSCRIPTION_FAILED';
            throw error;
          }
          if (status?.status === 'unknown') {
            consecutiveUnknownPolls += 1;
            if (consecutiveUnknownPolls >= 15) {
              throw new Error('服务器已找不到该转写任务，任务已停止，请重新开始');
            }
          } else if (status) {
            consecutiveUnknownPolls = 0;
          }
          if (consecutiveUnavailablePolls >= 30) {
            throw new Error('无法继续获取转写状态，任务已停止，请检查网络后重试');
          }
          if (status?.status === 'transcribing' && isActiveSession(session)
              && Number.isFinite(status.transcribed) && Number(status.total) > 0) {
            session.audioProgressMessage = tf('transcribingAudioSegments', {
              done: Math.max(0, Number(status.transcribed)), total: Number(status.total),
            });
            updateAudioTranscriptionLoading(session.audioProgressMessage, session.audioProgressDetail);
          }
          await new Promise((resolve) => setTimeout(resolve, ASYNC_ASR_POLL_INTERVAL_MS));
        }
        if (!response?.success) throw new Error('音频转写超时，请重试');
      }
      if (response.requestId !== session.audioRequestId || response.bvid !== session.videoData.bvid || String(response.cid || '') !== String(session.videoData.cid || '')) {
        throw new Error('音频转写结果与来源视频不匹配，已拒绝显示');
      }
      const transcript = String(response.text || '').trim();
      if (!transcript) throw new Error('语音转文字结果为空');
      session.subtitleResultMeta = createResultMetadata(session.videoData, session.tab);
      session.videoData.hasSubtitle = true;
      session.subtitleSegments = Array.isArray(response.segments) ? response.segments : [];
      session.lastSubtitleWasTranscribedAudio = session.subtitleSegments.length === 0;
      session.lastSubtitleText = transcript;
      session.completedAudioSummary = response.summary
        ? { summary: String(response.summary), jobId: String(response.jobId || '') }
        : null;
      session.slowAsrQueued = false;
      if (isActiveSession(session)) {
        videoData = session.videoData;
        subtitleResultMeta = session.subtitleResultMeta;
        subtitleSegments = session.subtitleSegments;
        lastSubtitleWasTranscribedAudio = session.lastSubtitleWasTranscribedAudio;
        lastSubtitleText = transcript;
        slowAsrQueued = false;
        updateEstimatedSummaryCredits(transcript);
        if (subtitleSegments.length) renderSubtitleText(transcript);
        else await revealAudioTranscript(transcript, session);
        if (expectedRunId !== session.runId || !isActiveSession(session)) return transcript;
        updateStudyActionState();
        renderFollowUpDock();
      }
      if (asrMode === 'fast') await refreshMembershipAccess();
      return transcript;
    } catch (err) {
      if (expectedRunId !== session.runId) return '';
      session.slowAsrQueued = false;
      session.error = normalizeUserFacingError(err.message || '音频转写失败', err.code);
      const message = normalizeUserFacingError(err.message || '音频转写失败', err.code);
      if (isActiveSession(session)) {
        slowAsrQueued = false;
        showSubtitleFailure(message);
        if (isLoginRequiredMessage(message)) showLoginRequiredPrompt(message);
        else setStatus('error', '❌', message);
      }
      return '';
    } finally {
      if (isActiveSession(session)) els.progress.classList.add('hidden');
    }
  }

  function clearStatus() {
    els.statusBar.classList.add('hidden');
    els.statusBar.className = 'status-bar hidden';
    els.statusIcon.textContent = '';
    els.statusText.textContent = '';
  }

  function setStatus(type, icon, text) {
    if (!text) { clearStatus(); return; }
    els.statusBar.className = 'status-bar' + (type ? ' ' + type : '');
    els.statusIcon.textContent = icon;
    els.statusText.textContent = normalizeUserFacingError(text);
  }

  function isLoginRequiredMessage(message) {
    const text = String(message || '');
    return text.includes('登录账户即可使用总结能力') || text.includes('请先登录账户') || text === '未登录';
  }

  function showLoginRequiredPrompt(message = '登录账户即可使用总结能力哟~') {
    showLocalAdvancedNotice({
      icon: '🔐', title: '未登录', content: message,
      actionLabel: '前往登录', actionType: 'login',
    }, 'login');
  }

  function hideLoginRequiredPrompt() {
    if (activeAdvancedNotice?.source === 'login') {
      advancedNoticeClosedSources.add('login');
      hideAdvancedNotice();
    }
  }

  function showToast(msg, type = '', options = {}) {
    const bottom = typeof options === 'object' && !!options.bottom;
    let host = document.getElementById(bottom ? 'global-toast-bottom-container' : 'global-toast-container');
    if (!host) {
      host = document.createElement('div');
      host.id = bottom ? 'global-toast-bottom-container' : 'global-toast-container';
      document.body.appendChild(host);
    }
    if (options.replace) host.textContent = '';
    const t = document.createElement('div');
    t.className = 'toast ' + type;
    t.textContent = sanitizePopupMessage(msg);
    host.appendChild(t);
    const duration = typeof options === 'number'
      ? options
      : (typeof options === 'object' && options.duration) || 3010;
    if (typeof options === 'object' && options.duration) {
      t.style.animation = 'toastIn 0.25s ease, toastOut 0.3s ease ' + Math.max(0, duration - 300) + 'ms forwards';
    }
    setTimeout(() => t.remove(), duration);
  }

  async function notifySummaryCompleteOnPage(tab = currentTab, expectedVideo = videoData) {
    if (!tab?.id) return;
    try {
      const liveTab = await chrome.tabs.get(tab.id);
      if (!videoDataMatchesTab(expectedVideo, liveTab)) return;
      await chrome.tabs.sendMessage(tab.id, { action: 'showSummaryCompleteToast' });
    } catch (err) {
      console.debug('[BiliSummary] summary complete toast failed:', err?.message || err);
    }
  }

  function normalizeUserFacingError(message, code) {
    const text = unpackJsonErrorText(message);
    if (CLOSED_MESSAGE_CHANNEL_PATTERN.test(text)) return '页面连接已更新，请重试';
    if (code === 'APP_CREDIT_INSUFFICIENT') return CREDIT_INSUFFICIENT_MESSAGE;
    if (code === 'PROVIDER_CREDIT_INSUFFICIENT') return PROVIDER_CREDIT_INSUFFICIENT_MESSAGE;
    if (code === 'PROVIDER_CONFIG_ERROR') return text || PROVIDER_CONFIG_ERROR_MESSAGE;
    if (/API 调用失败[:：].*学分不足|API 调用失败[:：].*余额不足|学分不足|余额不足|insufficient/i.test(text)) {
      return PROVIDER_CREDIT_INSUFFICIENT_MESSAGE;
    }
    return text;
  }

  function unpackJsonErrorText(message) {
    let text = String(message || '').trim();
    const prefixMatch = text.match(/^([^{}]*?:\s*)(\{[\s\S]*\})$/);
    const jsonText = prefixMatch ? prefixMatch[2] : (/^\s*\{/.test(text) ? text : '');
    if (!jsonText) return text;
    try {
      const parsed = JSON.parse(jsonText);
      const inner = parsed?.error?.message || parsed?.error || parsed?.message;
      if (inner) return (prefixMatch ? prefixMatch[1] : '') + String(inner).trim();
    } catch {}
    return text;
  }

  function throwResponseError(response, fallback) {
    const err = new Error(response?.error || fallback);
    if (response?.code) err.code = response.code;
    throw err;
  }

  function ensureSubtitleMatchesCurrent(response) {
    const data = response?.data || {};
    if (!videoData || !data?.bvid || !data?.cid) throw new Error('字幕缺少视频身份，已拒绝显示');
    if (data.bvid !== videoData.bvid) {
      throw new Error('字幕与当前视频不匹配，请重新检测视频后重试');
    }
    if (String(data.cid) !== String(videoData.cid)) {
      throw new Error('字幕与当前分P不匹配，请重新检测视频后重试');
    }
  }

  function sanitizePopupMessage(message) {
    const text = normalizeUserFacingError(message);
    if (!text) return '操作失败，请稍后重试';
    if (/上游地址配置错误|OpenAI-compatible/i.test(text)) {
      return text.length > 120 ? text.slice(0, 120) + '…' : text;
    }
    if (text.includes('api.8612306.xyz')) {
      return '服务配置异常，请稍后重试';
    }
    if (/<!doctype|<html|<head|<body|<title|<script/i.test(text)) {
      return '服务配置异常，请稍后重试';
    }
    return text.length > 80 ? text.slice(0, 80) + '…' : text;
  }

  function isProviderApiUrl(url) {
    return /api\.8612306\.xyz|xiaomimimo|openai|chatgpt|librechat/i.test(String(url || ''));
  }

  function updateProgress(step, detail) {
    const m = {
      subtitle: { p: 30, t: t('gettingSubtitle') },
      summarize: { p: 70, t: t('generating') },
      done: { p: 100, t: t('done') },
      error: { p: 0, t: detail || '出错了' },
    };
    const s = m[step] || { p: 0, t: detail || step };
    els.progressFill.style.width = s.p + '%';
    els.progressText.textContent = s.t;
  }

  // ============ 风格提示词 ============
  function getStylePrompt() {
    const v = els.styleSelect.value;
    const built = {
      detailed: t('detailedPrompt'),
      standard: t('standardPrompt'),
      concise: t('concisePrompt'),
    };
    if (built[v]) return built[v];
    const opt = els.styleSelect.querySelector(`option[value="${v}"]`);
    return opt?.title || '请总结以下视频内容。';
  }

  function getKimiPrompt(data) {
    const selectedStyle = els.styleSelect.value;
    const styleNames = {
      detailed: '详细无漏',
      standard: '主次分明',
      concise: '简明扼要',
    };
    const styleName = styleNames[selectedStyle] || '自定义风格';
    return [
      '任务：请总结随附的视频音频。',
      `视频标题：《${learningRecordTitle(data)}》`,
      `总结风格：${styleName}`,
      `具体要求：${getStylePrompt().trim()}`,
    ].join('\n\n');
  }

  async function startKimiSummary() {
    if (!videoData || !currentTab?.id || kimiDownloadBusy) return;
    if (Number(videoData.duration || 0) > 2 * 60 * 60) {
      showToast('Kimi音频总结只支持2小时以内的视频', 'info');
      return;
    }
    const sourceTab = { id: currentTab.id, index: currentTab.index, windowId: currentTab.windowId };
    const sourceIdentity = videoSessionKey(videoData, currentTab);
    const prompt = getKimiPrompt(videoData);
    const jobId = crypto.randomUUID();
    kimiDownloadBusy = true;
    activeKimiJobId = jobId;
    updateAudioSummaryActions();
    try {
      if (!collectionDirectoryHandle) throw new Error('请先在设置中选择收藏夹所在文件夹');
      if (!(await ensureCollectionDirectoryPermission(true))) throw new Error('收藏夹未授权，请在设置中重新选择');
      await requireLoggedInSession();
      await copyTextToClipboard(prompt);
      await updateKimiSummaryStatus(jobId, '正在准备音频...', 0, false, false, KIMI_COMPRESSION_NOTICE);
      const createOptions = { url: `https://www.kimi.com/?chat_enter_method=new_chat&bili_summary_job=${encodeURIComponent(jobId)}`, active: true };
      if (sourceTab.index !== undefined) createOptions.index = sourceTab.index + 1;
      if (sourceTab.windowId !== undefined) createOptions.windowId = sourceTab.windowId;
      await chrome.tabs.create(createOptions);
      const fresh = await chrome.tabs.sendMessage(sourceTab.id, { action: 'getVideoInfo' });
      if (!fresh?.success) throw new Error(fresh?.error || '无法读取当前视频');
      if (videoSessionKey(fresh.data, sourceTab) !== sourceIdentity) throw new Error('视频页面已切换，请重新点击 Kimi总结');
      const context = Object.freeze({
        tabId: sourceTab.id,
        bvid: String(fresh.data.bvid || ''),
        cid: String(fresh.data.cid || ''),
        epId: String(fresh.data.epId || ''),
        page: Number(fresh.data.page || 1),
        part: String(fresh.data.part || ''),
        title: learningRecordTitle(fresh.data),
        pages: [],
      });
      if (!fresh.data.audioUrl && !fresh.data.audioUrls?.length) throw new Error('暂时未找到可提取的音频流');
      const prepared = await chrome.runtime.sendMessage({
        action: 'prepareKimiAudio', requestId: jobId,
        tabId: context.tabId, bvid: context.bvid, cid: context.cid, epId: context.epId,
        duration: Number(fresh.data.duration || 0),
        audioUrl: fresh.data.audioUrl,
        audioUrls: fresh.data.audioUrls,
        audioMimeType: fresh.data.audioMimeType,
      });
      if (!prepared?.success) throw new Error(prepared?.error || 'Kimi 音频压缩失败');
      if (prepared.requestId !== jobId || prepared.bvid !== context.bvid || String(prepared.cid || '') !== context.cid) {
        throw new Error('Kimi 音频结果与当前视频不匹配，已拒绝保存');
      }
      const filename = await writePreparedKimiAudio(collectionDirectoryHandle, context.title, prepared, (loaded, total) => {
        void updateKimiSummaryStatus(jobId, '正在保存压缩音频...', 80 + loaded / Math.max(1, total) * 20, false, false, KIMI_COMPRESSION_NOTICE);
      });
      await updateKimiSummaryStatus(jobId, `音频已保存：${filename}`, 100, true);
    } catch (err) {
      await updateKimiSummaryStatus(jobId, err.message || '打开 Kimi 失败', 0, false, true);
      showToast(err.message || '打开 Kimi 失败', 'error', 5000);
    } finally {
      kimiDownloadBusy = false;
      if (activeKimiJobId === jobId) activeKimiJobId = '';
      updateAudioSummaryActions();
    }
  }

  function updateKimiSummaryStatus(jobId, message, percent, complete = false, error = false, notice = '') {
    const value = Math.max(0, Math.min(100, Math.round(Number(percent || 0))));
    kimiStatusWrite = kimiStatusWrite.catch(() => {}).then(() => chrome.storage.local.set({
      kimiSummaryStatus: { jobId, message, notice, percent: value, complete, error, updatedAt: Date.now() },
    }));
    return kimiStatusWrite;
  }

  async function copyTextToClipboard(text) {
    try {
      await navigator.clipboard.writeText(text);
    } catch (_) {
      const input = document.createElement('textarea');
      input.value = text;
      document.body.appendChild(input);
      input.select();
      document.execCommand('copy');
      input.remove();
    }
  }

  async function recordUsedCustomStylePrompt() {
    const selectedStyle = els.styleSelect.value || '';
    if (!selectedStyle.startsWith('custom_')) return;
    const stylePrompt = getStylePrompt().trim();
    if (!stylePrompt) return;
    try {
      const session = await requireLoggedInSession();
      const response = await fetch(defaultServerBaseUrl(session.serverBaseUrl) + '/api/user-events/custom-style-used', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + session.token },
        body: JSON.stringify({ style_prompt: stylePrompt }),
      });
      // A failed audit write must not turn a completed summary into a failure.
      if (!response.ok) return;
    } catch (_) {}
  }

  // ============ 总结 ============
  async function startAudioSummary(asrMode) {
    try {
      await chrome.permissions.request({ permissions: ['notifications'] });
    } catch (_) {
      // Notification permission is optional; the toolbar badge and history recovery still work.
    }
    return startSummary(asrMode);
  }

  async function startSummary(asrMode = '') {
    if (isProcessing || isAudioTranscribing || isSubtitleLoading || !videoData || !currentTab) return;
    // 点击时重新读取播放器状态：既防止 SPA 切换后沿用旧视频，也刷新
    // B 站带时效签名的音频 URL，避免检测完成很久后再点击导致 CDN 403。
    try {
      const fresh = await chrome.tabs.sendMessage(currentTab.id, { action: 'getVideoInfo' });
      if (fresh?.success) {
        const identityChanged = fresh.data.bvid !== videoData.bvid || fresh.data.cid !== videoData.cid;
        if (identityChanged) saveCurrentVideoSession();
        videoData = { ...videoData, ...fresh.data };
        if (identityChanged) {
    // 切换视频时不再弹出“检测中”状态条，仅清除旧提示避免残留
    clearStatus();
    lastSubtitleText = '';
          lastSubtitleWasTranscribedAudio = false;
          lastSummary = '';
          lastSummaryTranscript = '';
          subtitleResultMeta = null;
          summaryResultMeta = null;
          activeVideoSession = null;
          showVideoInfo(videoData);
          showToast('⚠️ 检测到页面已切换，已更新视频信息', 'info');
        }
      }
    } catch (e) {
      // 页面可能刚完成路由切换；后续页面上下文分块读取还会再次校验 BV 号。
    }
    if (!asrMode && isBilibiliLoginRequiredForSubtitle()) {
      showToast('B站账号未登录，无法获取字幕。', 'error', 5000);
      showBilibiliLoginPrompt();
      updateStartButtonIdle();
      return;
    }
    const session = saveCurrentVideoSession();
    if (!session) return;
    const runId = ++summaryRunId;
    session.runId = runId;
    session.error = '';
    session.userId = accountSettingsUserId;
    const sourceTab = { ...session.tab };
    const sourceVideo = session.videoData;
    const sourceTitle = learningRecordTitle();
    const selectedStylePrompt = getStylePrompt();
    const selectedLanguage = currentLanguage;
    const selectedMaxTokens = maxOutputTokens;
    const fallbackTranscript = (lastSubtitleText?.trim()
      || session.lastSubtitleText?.trim()
      || session.lastSummaryTranscript?.trim()
      || subtitleSegments.map((segment) => segment.content || '').join('').trim());
    if (fallbackTranscript) {
      lastSubtitleText = fallbackTranscript;
    } else if (asrMode !== 'slow' && asrMode !== 'fast') {
      // 有总结但无字幕文本的会话（如历史记录里字幕缺失）点击“再次总结”曾静默无反应，
      // 现在明确提示而不是假装按钮可用。
      showToast('暂无字幕文本，无法总结；请先加载字幕，或使用音频总结', 'error', 5000);
      return;
    }
    // 字幕超限时按钮已禁用，这里兜底拦截，避免任何残留路径进入总结流程。
    const lengthBlockMessage = summaryInputBlockMessage(lastSubtitleText);
    if (lengthBlockMessage) {
      showToast(lengthBlockMessage, 'error');
      updateStartButtonIdle();
      return;
    }
    if (!lastSubtitleText?.trim()) {
      if (asrMode !== 'slow' && asrMode !== 'fast') return;
      if (asrMode === 'slow' && Number(videoData.duration || 0) > SLOW_AUDIO_SUMMARY_MAX_DURATION_SECONDS) return;
      const confirmationKey = `${videoData.bvid}:${videoData.cid || ''}:${videoData.duration || 0}`;
      if (asrMode === 'fast' && Number(videoData.duration || 0) > 20 * 60 && pendingFastAsrConfirmationKey !== confirmationKey) {
        pendingFastAsrConfirmationKey = confirmationKey;
        showSubtitleFailure(`当前正在总结无字幕视频，需使用费用较高的音频转文字和总结，预估总消耗${estimatedAudioTotalCredits('fast')}学分，再次点击总结视为确认`);
        return;
      }
      pendingFastAsrConfirmationKey = '';
      isAudioTranscribing = true;
      session.isAudioTranscribing = true;
      els.btnStart.disabled = true;
      updateStartButtonProcessing();
      let transcript = '';
      try {
        transcript = await transcribeCurrentVideoAudio(session, runId, asrMode);
      } finally {
        if (runId === session.runId) {
          session.isAudioTranscribing = false;
          if (isActiveSession(session)) isAudioTranscribing = false;
        }
      }
      if (runId !== session.runId) return;
      if (!transcript) {
        if (isActiveSession(session)) {
          els.btnStart.disabled = false;
          updateStartButtonIdle();
        }
        return;
      }
      if (session.completedAudioSummary?.summary) {
        const completed = session.completedAudioSummary;
        const summaryMeta = session.subtitleResultMeta || createResultMetadata(sourceVideo, sourceTab);
        session.lastSummary = completed.summary;
        session.lastSummaryTranscript = transcript;
        session.summaryResultMeta = summaryMeta;
        session.currentResultMode = 'summary';
        session.completedAudioSummary = null;
        if (isActiveSession(session)) {
          lastSummary = completed.summary;
          lastSummaryTranscript = transcript;
          summaryResultMeta = summaryMeta;
          currentResultMode = 'summary';
          showResult(completed.summary);
          updateProgress('done');
          updateStartButtonSuccess();
        }
        const resultId = completed.jobId || crypto.randomUUID();
        void persistCompletedSummary(session, resultId).then(async (saved) => {
          try {
            const serverSession = await requireLoggedInSession();
            if (saved) await acknowledgePendingSummaryResults(serverSession, [resultId]);
            await chrome.runtime.sendMessage({ action: 'ackAudioSummaryJob', jobId: resultId });
          } catch (_) {}
        });
        void refreshMembershipAccess().catch(() => {});
        return;
      }
    }
    const waitsForFirstSummaryDelta = session.lastSubtitleWasTranscribedAudio;

    session.isProcessing = true;
    if (isActiveSession(session)) {
      isProcessing = true;
      followUpHistory = [];
      els.btnStart.disabled = true;
      updateStartButtonProcessing();
      if (!waitsForFirstSummaryDelta) showSummaryLoading(t('generatingInSummary'));
    }

    // Give the browser one frame to paint the disabled button and loading state
    // before any long subtitle work is resumed from the cache.
    await new Promise((resolve) => requestAnimationFrame(resolve));

    let summarySucceeded = false;
    let activeStreamRequestId = '';
    try {
      await requireLoggedInSession();
      let text = '';
      if (session.lastSubtitleText?.trim() || sourceVideo.hasSubtitle) {
        if (isActiveSession(session)) updateProgress('subtitle');
        if (session.lastSubtitleText) {
          // 已有缓存，直接用
          text = session.lastSubtitleText;
        } else {
          const r = await chrome.tabs.sendMessage(sourceTab.id, { action: 'getSubtitleText', bvid: sourceVideo.bvid, cid: sourceVideo.cid });
          if (!r?.success) throwResponseError(r, '获取字幕失败');
          if (r.data?.bvid && r.data.bvid !== sourceVideo.bvid) throw new Error('字幕与来源视频不匹配，请重试');
          if (r.data?.cid && String(r.data.cid) !== String(sourceVideo.cid)) throw new Error('字幕与来源分P不匹配，请重试');
          text = r.data.fullText;
          session.lastSubtitleText = text;
          session.lastSubtitleWasTranscribedAudio = false;
          session.subtitleResultMeta = createResultMetadata(sourceVideo, sourceTab);
          if (isActiveSession(session)) {
            lastSubtitleText = text;
            lastSubtitleWasTranscribedAudio = false;
            subtitleResultMeta = session.subtitleResultMeta;
          }
        }
      }
      if (!text?.trim()) throw new Error('获取到的内容为空');
      const blockMessage = summaryInputBlockMessage(text);
      if (blockMessage) {
        if (isActiveSession(session)) {
          showToast(blockMessage, 'error');
          els.progress.classList.add('hidden');
        }
        return;
      }
      const summaryCredits = getEstimatedSummaryCredits(text);
      if (isActiveSession(session)) updateEstimatedSummaryCredits(text);
      if (!await ensureLocalSummaryCreditThreshold(summaryCredits)) {
        if (isActiveSession(session)) showToast(CREDIT_INSUFFICIENT_MESSAGE, 'error');
        return;
      }
      if (isActiveSession(session)) reportVideoStats({ subtitleText: text });

      if (isActiveSession(session)) updateProgress('summarize');
      const summarySourceMeta = session.subtitleResultMeta || createResultMetadata(sourceVideo, sourceTab);
      if (!waitsForFirstSummaryDelta && isActiveSession(session)) showSummaryLoading(t('generatingInSummary'));
      const streamRequestId = crypto.randomUUID();
      activeStreamRequestId = streamRequestId;
      let streamedSummary = '';
      let streamDisplayReady = false;
      let streamTransitionStarted = false;
      let streamTransition = Promise.resolve();
      let streamAbandoned = false;
      let streamWatchdogTimer = null;
      const clearStreamWatchdog = () => {
        if (streamWatchdogTimer) {
          clearTimeout(streamWatchdogTimer);
          streamWatchdogTimer = null;
        }
      };
      streamHandlers.set(streamRequestId, (delta) => {
        if (runId !== session.runId || streamAbandoned) return;
        clearStreamWatchdog();
        streamedSummary += delta;
        session.lastSummary = streamedSummary;
        session.currentResultMode = 'summary';
        if (isActiveSession(session)) lastSummary = streamedSummary;
        if (waitsForFirstSummaryDelta) {
          if (!streamDisplayReady) {
            streamDisplayReady = true;
          }
          if (isActiveSession(session) && currentResultMode === 'summary') renderStreamingSummary(streamedSummary);
          return;
        }
        if (!streamTransitionStarted) {
          streamTransitionStarted = true;
          streamTransition = (isActiveSession(session) ? fadeSummaryLoading() : Promise.resolve()).then(() => {
            streamDisplayReady = true;
            if (runId === session.runId && isActiveSession(session) && currentResultMode === 'summary') renderStreamingSummary(streamedSummary);
          });
        } else if (streamDisplayReady && isActiveSession(session) && currentResultMode === 'summary') {
          renderStreamingSummary(streamedSummary);
        }
      });
      // 首包看门狗：2.5 分钟收不到第一个增量（后台无响应/服务端卡死）就放弃，
      // 不再让侧栏永远停在「正在生成总结...」；收到首个增量后自动取消。
      const streamWatchdog = new Promise((resolve) => {
        streamWatchdogTimer = setTimeout(() => resolve(true), SUMMARY_STALL_TIMEOUT_MS);
      });
      const responsePromise = chrome.runtime.sendMessage({
        action: 'summarize', text, stylePrompt: selectedStylePrompt,
        bvid: sourceVideo.bvid, title: sourceTitle, owner: sourceVideo.owner, duration: sourceVideo.duration, language: selectedLanguage, maxTokens: selectedMaxTokens, streamRequestId,
        summaryContext: {
          id: streamRequestId, bvid: sourceVideo.bvid, cid: String(sourceVideo.cid || ''), title: sourceTitle,
          owner: sourceVideo.owner || '', duration_seconds: Number(sourceVideo.duration || 0), url: summarySourceMeta.url || '',
          pic: String(sourceVideo.pic || ''), pages: Array.isArray(sourceVideo.pages) ? sourceVideo.pages.map((page) => ({
            cid: String(page?.cid || ''), page: Number(page?.page || 1), part: String(page?.part || ''), duration: Number(page?.duration || 0),
          })) : [],
        },
      });
      const r = await Promise.race([
        responsePromise,
        streamWatchdog.then(() => null),
      ]);
      clearStreamWatchdog();
      if (!r) {
        streamAbandoned = true;
        streamHandlers.delete(streamRequestId);
        activeStreamRequestId = '';
        throw new Error('服务响应超时，请稍后重试');
      }
      streamHandlers.delete(streamRequestId);
      activeStreamRequestId = '';
      if (runId !== session.runId) return;
      if (!r?.success) throwResponseError(r, '总结生成失败');
      await streamTransition;
      if (runId !== session.runId) return;
      session.lastSummary = r.summary;
      session.lastSummaryTranscript = text;
      session.summaryResultMeta = summarySourceMeta;
      session.currentResultMode = 'summary';
      if (isActiveSession(session)) {
        lastSummary = r.summary;
        lastSummaryTranscript = text;
        summaryResultMeta = summarySourceMeta;
        if (currentResultMode === 'summary') showResult(r.summary);
      }
      summarySucceeded = true;
      void persistCompletedSummary(session, streamRequestId).then(async (saved) => {
        if (!saved) return;
        try {
          const serverSession = await requireLoggedInSession();
          await acknowledgePendingSummaryResults(serverSession, [streamRequestId]);
        } catch (_) {}
      });
      if (isActiveSession(session) && autoSaveSummaryAsTxt && isPaidMembershipTier(currentUserTier)) {
        void autoSaveCompletedSummaryAsTxt(summarySourceMeta, r.summary, text);
      }
      if (isActiveSession(session)) updateProgress('done');
      void recordUsedCustomStylePrompt();
      void refreshMembershipAccess().catch(() => {});
      if (isActiveSession(session) && currentResultMode === 'summary' && isPaidMembershipTier(currentUserTier) && autoScrollStreamingSummary) {
        els.summaryView.scrollTop = els.summaryView.scrollHeight;
      }
      notifySummaryCompleteOnPage(sourceTab, sourceVideo);
    } catch (err) {
      if (runId !== session.runId) return;
      const message = normalizeUserFacingError(err.message || '总结生成失败', err.code);
      session.error = message;
      if (isActiveSession(session)) {
        els.progress.classList.add('hidden');
        if (els.summaryView.querySelector('.summary-generating-placeholder')) {
          els.summaryView.textContent = '总结生成失败，请重试';
        }
        if (isLoginRequiredMessage(message)) {
          showLoginRequiredPrompt(message);
        } else {
          setStatus('error', '❌', message);
        }
      }
    }
    finally {
      if (activeStreamRequestId) streamHandlers.delete(activeStreamRequestId);
      if (runId !== session.runId) return;
      session.isProcessing = false;
      session.isAudioTranscribing = false;
      if (isActiveSession(session)) {
        isProcessing = false;
        isAudioTranscribing = false;
        els.btnStart.disabled = false;
        if (summarySucceeded) updateStartButtonSuccess();
        else updateStartButtonIdle();
        updateSummaryCommentButton();
        setTimeout(() => {
          if (isActiveSession(session)) els.progress.classList.add('hidden');
        }, 1500);
      }
    }
  }

  function escapeHtml(text) {
    return String(text ?? '')
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;');
  }

  function splitMarkdownRow(row) {
    let value = row.trim();
    if (value.startsWith('|')) value = value.slice(1);
    if (value.endsWith('|')) value = value.slice(0, -1);
    return value.split('|');
  }

  function chunkText(text, size = 10) {
    const chars = Array.from(text);
    const chunks = [];
    for (let i = 0; i < chars.length; i += size) {
      chunks.push(chars.slice(i, i + size).join(''));
    }
    return chunks;
  }

  function chunkTextPreservingMath(text, size = 10) {
    const mathMarker = /(@@BILI_MATH_(?:BLOCK|INLINE)_\d+@@)/g;
    return String(text ?? '').split(mathMarker).flatMap((part) => {
      if (!part) return [];
      if (/^@@BILI_MATH_(?:BLOCK|INLINE)_\d+@@$/.test(part)) return [part];
      return chunkText(part, size);
    });
  }

  function renderTableCell(rawCell) {
    return String(rawCell ?? '')
      .trim()
      .split(/<br\s*\/?>/i)
      .flatMap((line) => {
        const chunks = chunkTextPreservingMath(line.trim(), 10);
        return chunks.length ? chunks : [''];
      })
      .map(escapeHtml)
      .join('<br>');
  }

  function renderMarkdownTable(header, body) {
    const ths = splitMarkdownRow(header)
      .map((cell) => '<th>' + renderTableCell(cell) + '</th>')
      .join('');
    const rows = body.trim().split('\n').map((row) => {
      const tds = splitMarkdownRow(row)
        .map((cell) => '<td>' + renderTableCell(cell) + '</td>')
        .join('');
      return '<tr>' + tds + '</tr>';
    }).join('');
    return '<div class="table-wrap"><table><thead><tr>' + ths + '</tr></thead><tbody>' + rows + '</tbody></table></div>';
  }

  const MATH_DELIMITERS = [
    { left: '$$', right: '$$', display: true },
    { left: '\\(', right: '\\)', display: false },
    { left: '\\begin{equation}', right: '\\end{equation}', display: true },
    { left: '\\begin{align}', right: '\\end{align}', display: true },
    { left: '\\begin{alignat}', right: '\\end{alignat}', display: true },
    { left: '\\begin{gather}', right: '\\end{gather}', display: true },
    { left: '\\begin{CD}', right: '\\end{CD}', display: true },
    { left: '\\[', right: '\\]', display: true },
  ];

  function normalizeInlineDollarMath(markdown) {
    return String(markdown ?? '').replace(
      /(^|[^\\$])\$(?![$\s])([^$\r\n]*?\S)\$(?![$\d])/g,
      (_, prefix, math) => prefix + '\\(' + math + '\\)'
    );
  }

  function extractRenderedMath(markdown) {
    const original = String(markdown ?? '');
    if (typeof renderMathInElement !== 'function') return { markdown: original, fragments: [] };

    const scratch = document.createElement('div');
    scratch.textContent = normalizeInlineDollarMath(original);
    try {
      renderMathInElement(scratch, {
        delimiters: MATH_DELIMITERS,
        output: 'htmlAndMathml',
        throwOnError: true,
        strict: 'ignore',
        trust: false,
        maxSize: 10,
        maxExpand: 1000,
        errorCallback: () => {},
      });
    } catch {
      return { markdown: original, fragments: [] };
    }

    const fragments = [];
    const mathNodes = Array.from(scratch.querySelectorAll('.katex-display, .katex'))
      .filter((node) => !node.parentElement?.closest('.katex-display, .katex'));
    mathNodes.forEach((node, index) => {
      const display = node.classList.contains('katex-display');
      const source = node.querySelector('annotation[encoding="application/x-tex"]')?.textContent || '';
      const marker = '@@BILI_MATH_' + (display ? 'BLOCK' : 'INLINE') + '_' + index + '@@';
      node.classList.add('bili-math');
      node.setAttribute('data-math-source', display ? '\\[' + source + '\\]' : '\\(' + source + '\\)');
      fragments.push({ marker, html: node.outerHTML });
      node.replaceWith(document.createTextNode(marker));
    });
    return { markdown: scratch.textContent || '', fragments };
  }

  function renderMarkdown(md) {
    const renderedMath = extractRenderedMath(md);
    const tables = [];
    let html = renderedMath.markdown.replace(/^(\|.+\|)\n(\|[-| :]+\|)\n((?:\|.+\|\n?)+)/gm, (_, header, sep, body) => {
      const key = '@@BILI_TABLE_' + tables.length + '@@';
      tables.push(renderMarkdownTable(header, body));
      return '\n' + key + '\n';
    });

    html = escapeHtml(html)
      .replace(/^#{4,}\s+(.+)$/gm, '<strong>$1</strong>')
      .replace(/^### (.+)$/gm, '<h3>$1</h3>')
      .replace(/^## (.+)$/gm, '<h2>$1</h2>')
      .replace(/^# (.+)$/gm, '<h1>$1</h1>')
      .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
      .replace(/\*(.+?)\*/g, '<em>$1</em>')
      .replace(/^[-*] (.+)$/gm, '<li>$1</li>')
      .replace(/(<li>.*<\/li>)/gm, '<ul>$1</ul>')
      .replace(/<\/ul>\s*<ul>/g, '')
      .replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
      .replace(/^---+$/gm, '<hr>');

    html = html.split('\n').map((line) => line.trim());
    // 连续普通文本行按空行分组为 <p> 段落，段间距由 CSS 的 p 边距提供；
    // 块级标记（标题/列表/引用/表格/分隔线）各自独立成块，不被包进 <p>。
    const blockStart = /^(<h[1-6]|<li>|<blockquote>|<hr>|@@BILI_(?:TABLE_\d+|MATH_BLOCK_\d+)@@|<\/?(?:ul|ol|div|table|thead|tbody|tr|th|td))/;
    const blocks = [];
    let paragraph = [];
    const flushParagraph = () => {
      if (!paragraph.length) return;
      blocks.push('<p>' + paragraph.join('<br>') + '</p>');
      paragraph = [];
    };
    for (const line of html) {
      if (!line) { flushParagraph(); continue; }
      if (blockStart.test(line)) { flushParagraph(); blocks.push(line); }
      else paragraph.push(line);
    }
    flushParagraph();
    html = blocks.join('\n');
    tables.forEach((table, index) => {
      html = html.replace('@@BILI_TABLE_' + index + '@@', table);
    });
    html = html.replace(/&lt;br\s*\/?&gt;/gi, '<br>');
    html = html.replace(/\n/g, '<br>');
    html = html.replace(/<\/(h[1-6]|ul|ol|blockquote|hr|div|p|table)><br>/g, '</$1>');
    html = html.replace(/<br><(h[1-6]|ul|ol|blockquote|hr|div|p|table)/g, '<$1');
    html = html.replace(/<br>(@@BILI_MATH_BLOCK_\d+@@)/g, '$1');
    html = html.replace(/(@@BILI_MATH_BLOCK_\d+@@)<br>/g, '$1');
    renderedMath.fragments.forEach((fragment) => {
      html = html.split(fragment.marker).join(fragment.html);
    });
    return html;
  }

  function showResult(md) {
    els.result.classList.remove('hidden');
    els.summaryView.innerHTML = renderMarkdown(toDisplayLanguage(md));
    updateResultTabs('summary');
    renderFollowUpDock();
    els.summaryView.scrollTop = 0;
    saveCurrentVideoSession();
  }

  function switchResultMode(mode) {
    if (mode === 'summary') {
      if (lastSummary) {
        if (isProcessing) {
          els.result.classList.remove('hidden');
          renderStreamingSummary(lastSummary);
          updateResultTabs('summary');
          renderFollowUpDock();
        } else {
          showResult(lastSummary);
        }
      } else if (isAudioTranscribing) {
        showSubtitleLoading(activeVideoSession?.audioProgressMessage || t('transcribingAudio'), activeVideoSession?.audioProgressDetail || '');
      } else if (isProcessing) {
        showSummaryLoading(t('generatingInSummary'));
      } else {
        showSummaryPending();
        return;
      }
    } else if (mode === 'subtitle') {
      if (lastSubtitleText) {
        // 字幕视图常驻 DOM：已有内容时直接切换可见性，避免上万节点
        // 反复销毁重建；未渲染完则继续渐进追加。
        if (els.subtitleView.childElementCount > 0) {
          updateResultTabs('subtitle');
          renderFollowUpDock();
          applySubtitleSearchState();
          scheduleSubtitleProgressiveRender();
          startSubtitleFollow();
        } else {
          renderSubtitleText(lastSubtitleText, { follow: true });
        }
      } else {
        showSubtitleNotFound();
      }
    }
  }

  function canFollowUp() {
    return !!((lastSummary && lastSummary.trim()) || (lastSubtitleText && lastSubtitleText.trim()));
  }

  function canUseFollowUp() {
    return currentUserTier === 'admin' || currentUserTier === 'duxue' || currentUserTier === 'mengxue';
  }

  function renderFollowUpDock() {
    if (!els.followUpDock || !els.followUpInput || !els.followUpSubmit) return;
    const hasContext = canFollowUp();
    const hasAccess = canUseFollowUp();
    const enabled = hasContext && hasAccess && !isFollowUpProcessing;
    const resultVisible = !els.result.classList.contains('hidden');
    els.followUpDock.classList.toggle('hidden', !resultVisible);
    els.followUpDock.classList.toggle('is-locked', hasContext && !hasAccess);
    els.followUpDock.setAttribute('aria-disabled', enabled ? 'false' : 'true');
    els.followUpDock.setAttribute('aria-describedby', hasContext && !hasAccess ? 'followup-lock-tip' : '');
    els.followUpDock.tabIndex = hasContext && !hasAccess ? 0 : -1;
    els.followUpInput.disabled = !enabled;
    els.followUpSubmit.disabled = !enabled;
    els.followUpSubmit.textContent = isFollowUpProcessing ? t('answering') : t('send');
    const isSummaryMode = currentResultMode === 'summary';
    const context = (isSummaryMode ? lastSummary : lastSubtitleText) || lastSummary || lastSubtitleText || '';
    const count = toDisplayLanguage(context).replace(/\s/g, '').length;
    els.followUpInput.placeholder = !hasContext
      ? t('followUpWaiting')
      : isSummaryMode && !lastSummary
        ? t('followUpNeedsSummary')
        : isSummaryMode
          ? tf('askAboutSummary', { count })
          : tf('askAboutTranscript', { count });
    if (!hasContext) els.followUpInput.value = '';
    const carry = carryTranscript;
    if (els.followUpAttach) {
      // 别针仅总结模式显示：字幕模式本就是基于字幕追问，无需携带开关。
      els.followUpAttach.classList.toggle('hidden', !isSummaryMode);
      els.followUpAttach.closest('.followup-input-wrap')?.classList.toggle('no-attach', !isSummaryMode);
      els.followUpAttach.classList.toggle('active', carry);
      els.followUpAttach.setAttribute('aria-pressed', carry ? 'true' : 'false');
    }
    if (els.followUpAttachTip) els.followUpAttachTip.textContent = carry ? t('followUpCarryOn') : t('followUpCarryOff');
  }


  function appendFollowUpQuestion(question) {
    const questionCard = document.createElement('div');
    questionCard.className = 'followup-question';
    questionCard.innerHTML = '<div class="followup-question-title">' + t('question') + '</div><div class="followup-question-body"></div>';
    questionCard.querySelector('.followup-question-body').textContent = question;
    activeResultView().appendChild(questionCard);
    return questionCard;
  }

  function appendFollowUpAnswer(loadingText = t('generatingAnswer')) {
    const answerCard = document.createElement('div');
    answerCard.className = 'followup-answer';
    answerCard.innerHTML = '<div class="followup-answer-title">' + t('answer') + '</div><div class="followup-answer-body followup-loading"></div>';
    answerCard.querySelector('.followup-answer-body').textContent = loadingText;
    activeResultView().appendChild(answerCard);
    activeResultView().scrollTop = activeResultView().scrollHeight;
    return answerCard.querySelector('.followup-answer-body');
  }

  // 追问内容跟随当前页签所在视图（总结追问进总结视图、字幕追问进字幕视图）
  function activeResultView() {
    return currentResultMode === 'summary' ? els.summaryView : els.subtitleView;
  }

  function delay(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  function shouldRetryFollowUpError(err) {
    const message = normalizeUserFacingError(err.message || '', err.code);
    if (isLoginRequiredMessage(message)) return false;
    if (err.code === 'APP_CREDIT_INSUFFICIENT' || err.code === 'PROVIDER_CREDIT_INSUFFICIENT' || err.code === 'PROVIDER_CONFIG_ERROR') return false;
    return /API\s*调用失败|上游|timeout|timed out|network|fetch|Failed to fetch|返回内容为空|模型返回/i.test(message);
  }

  async function sendFollowUpWithRetry(payload, answerBody) {
    let lastError = null;
    for (let attempt = 1; attempt <= FOLLOW_UP_MAX_ATTEMPTS; attempt++) {
      if (attempt > 1) {
        answerBody.classList.add('followup-loading');
        answerBody.textContent = `正在生成回答...（第 ${attempt} 次尝试）`;
      }
      try {
        const streamRequestId = crypto.randomUUID();
        let streamedAnswer = '';
        streamHandlers.set(streamRequestId, (delta) => {
          streamedAnswer += delta;
          answerBody.classList.remove('followup-loading');
          const answerView = answerBody.closest('.result-view') || els.summaryView;
          const previousScrollTop = answerView.scrollTop;
          answerBody.innerHTML = renderMarkdown(toDisplayLanguage(streamedAnswer));
          answerView.scrollTop = previousScrollTop;
        });
        let r;
        try {
          r = await chrome.runtime.sendMessage({ ...payload, streamRequestId });
        } finally {
          streamHandlers.delete(streamRequestId);
        }
        if (r?.success) return r;
        throwResponseError(r, '追问失败');
      } catch (err) {
        lastError = err;
        if (attempt >= FOLLOW_UP_MAX_ATTEMPTS || !shouldRetryFollowUpError(err)) throw err;
        await delay(600 * attempt);
      }
    }
    throw lastError || new Error('追问失败');
  }

  async function submitFollowUp() {
    if (isFollowUpProcessing) return;
    if (!canFollowUp()) {
      showToast('请先加载字幕或生成总结', 'error');
      return;
    }
    if (!canUseFollowUp()) {
      showToast('当前仅笃学会员、博学会员支持追问', 'info');
      return;
    }
    const question = els.followUpInput.value.trim();
    if (!question) {
      showToast('请输入追问内容', 'error');
      return;
    }

    // 携带字幕是单次行为：每问一次都回到未携带，下次需要重新点击别针。
    carryTranscript = false;
    isFollowUpProcessing = true;
    els.followUpInput.value = '';
    renderFollowUpDock();

    appendFollowUpQuestion(question);
    const answerBody = appendFollowUpAnswer();

    try {
      const r = await sendFollowUpWithRetry({
        action: 'followUp',
        question,
        summary: lastSummary,
        transcript: (carryTranscript || currentResultMode !== 'summary') ? lastSubtitleText : '',
        history: followUpHistory,
        bvid: videoData?.bvid,
        title: videoData?.title,
        owner: videoData?.owner,
        duration: videoData?.duration,
        language: currentLanguage,
        maxTokens: maxOutputTokens,
      }, answerBody);

      answerBody.classList.remove('followup-loading');
      answerBody.innerHTML = renderMarkdown(toDisplayLanguage(r.answer));
      followUpHistory.push({ role: 'user', content: question });
      followUpHistory.push({ role: 'assistant', content: r.answer });
      isFollowUpProcessing = false;
      renderFollowUpDock();
    } catch (err) {
      const message = normalizeUserFacingError(err.message || '追问失败', err.code);
      answerBody.classList.remove('followup-loading');
      answerBody.textContent = '追问失败: ' + message;
      els.followUpInput.value = question;
      isFollowUpProcessing = false;
      renderFollowUpDock();
      if (isLoginRequiredMessage(message)) showLoginRequiredPrompt(message);
      showToast('追问失败: ' + message, 'error');
    }
  }

  function getRenderedResultText() {
    const clone = activeResultView().cloneNode(true);
    clone.removeAttribute('id');
    clone.classList.remove('result-view', 'hidden');
    clone.querySelectorAll('[data-math-source]').forEach((math) => {
      const source = math.getAttribute('data-math-source') || '';
      if (math.classList.contains('katex-display')) {
        const block = document.createElement('div');
        block.textContent = source;
        math.replaceWith(block);
      } else {
        math.replaceWith(document.createTextNode(source));
      }
    });
    const holder = document.createElement('div');
    holder.style.cssText = 'position:fixed;left:-10000px;top:0;width:360px;opacity:0;pointer-events:none;';
    holder.appendChild(clone);
    document.body.appendChild(holder);
    try {
      return clone.innerText;
    } finally {
      holder.remove();
    }
  }

  async function copyResult() {
    const text = getRenderedResultText();
    try { await navigator.clipboard.writeText(text); showToast('已复制到剪贴板'); }
    catch { const t = document.createElement('textarea'); t.value = text; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); showToast('已复制到剪贴板'); }
  }

  async function commentCurrentVideoWithSummary() {
    const summary = getShareableSummary();
    if (!currentTab?.id || !videoData?.aid || !summary) {
      showToast('暂无可评论的总结', 'error');
      return;
    }
    els.btnShareComment.disabled = true;
    try {
      const publicConfig = await fetchMobilePublicConfig();
      const response = await chrome.tabs.sendMessage(currentTab.id, {
        action: 'commentVideoWithSummary',
        aid: videoData.aid,
        bvid: videoData.bvid,
        summary,
        footer: publicConfig.shareCommentFooter || DEFAULT_SHARE_COMMENT_FOOTER,
      });
      if (!response?.success) throw new Error(response?.error || '评论失败');
      showToast(`已评论当前视频，共${response.count || 1}条`, 'success');
    } catch (err) {
      showToast('评论失败: ' + (err.message || '请稍后重试'), 'error');
    } finally {
      els.btnShareComment.disabled = false;
    }
  }

  function buildBookmarkedContent(isSubtitleMode, content) {
    const primaryContent = String(content || '').trimEnd();
    if (isSubtitleMode || !includeTranscriptInSummaryBookmark || !lastSummaryTranscript.trim()) {
      return primaryContent;
    }
    const divider = '========================================';
    return primaryContent + '\n\n\n' + divider + '\n' + t('originalTranscript') + '\n' + divider + '\n\n' + lastSummaryTranscript.trim();
  }

  async function autoSaveCompletedSummaryAsTxt(resultMeta, summary, transcript) {
    if (!resultMeta?.title || !summary?.trim()) return;
    if (!collectionDirectoryHandle || !(await ensureCollectionDirectoryPermission(false))) {
      showToast('自动保存未完成：请在设置中选择并授权收藏夹', 'info');
      return;
    }
    const primaryContent = String(summary).trimEnd();
    const divider = '========================================';
    const content = includeTranscriptInSummaryBookmark && String(transcript || '').trim()
      ? primaryContent + '\n\n\n' + divider + '\n' + t('originalTranscript') + '\n' + divider + '\n\n' + String(transcript).trim()
      : primaryContent;
    try {
      const filename = await writeCollectionFile(collectionDirectoryHandle, resultMeta, content);
      showToast('已自动保存: ' + filename, 'success');
    } catch (err) {
      showToast('自动保存失败: ' + collectionErrorMessage(err), 'error');
    }
  }

  async function bookmarkResult() {
    if (!videoData) return;
    if (!canBookmarkToFolder()) {
      showToast('当前仅笃学会员、博学会员支持收藏到指定文件夹', 'info');
      return;
    }
    const isSubtitleMode = currentResultMode === 'subtitle';
    const content = isSubtitleMode ? lastSubtitleText : lastSummary;
    const resultMeta = isSubtitleMode ? subtitleResultMeta : summaryResultMeta;
    if (!content || !content.trim()) {
      showToast(isSubtitleMode ? '暂无字幕可收藏' : '暂无总结可收藏', 'error');
      return;
    }
    if (!resultMeta?.title) {
      showToast('无法确认当前内容对应的视频标题，请刷新后重试', 'error');
      return;
    }
    const bookmarkedContent = buildBookmarkedContent(isSubtitleMode, content);
    const pickerResult = await saveCollectionWithPicker(resultMeta, bookmarkedContent);
    if (pickerResult.handled) {
      if (pickerResult.error) showToast('收藏失败: ' + pickerResult.error, 'error');
      else if (pickerResult.saved) showToast('已收藏: ' + pickerResult.filename, 'success');
      return;
    }
    var folder = 'collection';
    chrome.runtime.sendMessage({
      action: 'saveToCollection',
      title: resultMeta.title,
      contentType: isSubtitleMode ? 'subtitle' : 'summary',
      url: resultMeta.url,
      summary: bookmarkedContent,
      asMarkdown: saveTextsAsMarkdown,
      folder: folder,
    }, function(r) {
      if (!r?.success) showToast('收藏失败: ' + (r?.error || ''), 'error');
    });
  }

  // ============ 自动保存设置 ============
  function queueSettingsSave() {
    settingsSaveQueue = settingsSaveQueue
      .then(() => saveSettings())
      .catch((err) => showToast('设置保存失败: ' + err.message, 'error'));
  }

  async function saveSettings() {
    const defaults = getDefaultColors();
    const activeTitleSwatch = els.titleColorPalette.querySelector('.color-swatch.active');
    const activeSwatch = els.colorPalette.querySelector('.color-swatch.active');
    const activeStartSwatch = els.startColorPalette?.querySelector('.color-swatch.active');
    floatingButtonEnabled = !!els.floatingButtonEnabled?.checked;
    disableRealtimePageSwitch = !!els.disableRealtimePageSwitch?.checked;
    includeTranscriptInSummaryBookmark = !!els.includeTranscriptInSummaryBookmark?.checked;
    autoScrollStreamingSummary = !!els.autoScrollStreamingSummary?.checked;
    autoSaveSummaryAsTxt = !!els.autoSaveSummaryAsTxt?.checked;
    saveTextsAsMarkdown = !!els.saveTextsAsMarkdown?.checked;
    sidebarBackgroundMode = normalizeSidebarBackgroundMode(sidebarBackgroundMode);
    sidebarBackgroundDepth = normalizeSidebarBackgroundDepth(els.sidebarBackgroundDepth?.value);
    maxOutputTokens = normalizeMaxOutputTokens(els.maxOutputTokens?.value);
    if (els.maxOutputTokens) els.maxOutputTokens.value = String(maxOutputTokens);
    const cfg = {
      startButtonColor: activeStartSwatch?.dataset.color || defaults.startButtonColor,
      includeTranscriptInSummaryBookmark,
      autoScrollStreamingSummary,
      autoSaveSummaryAsTxt,
      saveTextsAsMarkdown,
      sidebarBackgroundMode,
      sidebarBackgroundDepth,
      maxOutputTokens,
    };
    displayScale = normalizeDisplayScale(DISPLAY_SCALE_OPTIONS[Number(els.displayScale?.value)] || displayScale);
    applyDisplayScale(displayScale);
    await chrome.storage.local.set({ displayScale });
    await chrome.storage.local.set({ disableRealtimePageSwitch });
    if (canCustomizeSummaryTextColors()) {
      cfg.titleColor = activeTitleSwatch?.dataset.color || defaults.titleColor;
      cfg.accentColor = activeSwatch?.dataset.color || defaults.accentColor;
    }
    const language = normalizeLanguage(els.displayLanguage?.value || currentLanguage);
    cfg.displayLanguage = language;
    if (language !== currentLanguage) {
      const result = await chrome.runtime.sendMessage({ action: 'serverUpdateLanguage', language });
      if (!result?.success) throw new Error(result?.error || '语言保存失败');
      currentLanguage = language;
      document.documentElement.lang = language;
      applyLanguageToControls();
      applyColorSettings(cfg);
    }
    const accountCfg = {};
    Object.entries(cfg).forEach(([key, value]) => {
      const scopedKey = accountStorageKey(key);
      if (scopedKey) accountCfg[scopedKey] = value;
    });
    if (Object.keys(accountCfg).length) await chrome.storage.local.set(accountCfg);
    const floatingResult = await chrome.runtime.sendMessage({ action: 'setFloatingButtonEnabled', enabled: floatingButtonEnabled });
    if (!floatingResult?.success) throw new Error(floatingResult?.error || '悬浮按钮设置保存失败');
  }

  function applyAccentColor(color) {
    document.documentElement.style.setProperty('--accent-color', color);
  }

  function applyTitleColor(color) {
    document.documentElement.style.setProperty('--title-color', color);
  }

  function applyStartButtonColor(color) {
    document.documentElement.style.setProperty('--start-button-color', color);
  }

  function setActiveSwatch(palette, color) {
    if (!palette) return;
    palette.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('active'));
    const target = palette.querySelector('[data-color="' + color + '"]');
    (target || palette.querySelector('.color-swatch'))?.classList.add('active');
  }

  function fmtDur(s) {
    if (!s) return t('unknown');
    const h = ~~(s / 3600), m = ~~((s % 3600) / 60), sec = s % 60;
    if (currentLanguage === 'en') return h ? `${h}h ${m}m ${sec}s` : `${m}m ${sec}s`;
    if (currentLanguage === 'zh-TW') return h ? `${h} 小時 ${m} 分 ${sec} 秒` : `${m} 分 ${sec} 秒`;
    return h ? `${h}小时${m}分${sec}秒` : `${m}分${sec}秒`;
  }
  async function initServer() {
    chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
      if (msg && msg.type === 'llmStreamChunk') {
        streamHandlers.get(msg.requestId)?.(String(msg.delta || ''));
        sendResponse({ ok: true });
        return;
      }
      if (msg && msg.action === 'biliSummaryCommentProgress') {
        const posted = Math.max(1, Number(msg.posted || 0));
        const total = Math.max(posted, Number(msg.total || 0));
        showToast(`已分享评论 ${posted}/${total}，暂时请勿离开`, 'info', { replace: true });
        sendResponse({ ok: true });
        return;
      }
      if (msg && msg.type === 'audioTranscriptionProgress') {
        const session = [...videoSessions.values()].find((item) => item.audioRequestId && item.audioRequestId === msg.requestId);
        if (session) {
          const percent = Math.max(0, Math.min(100, Math.round(Number(msg.percent || 0))));
          session.slowAsrQueued = msg.phase === 'queued';
          session.audioProgressMessage = msg.phase === 'queued'
            ? `高峰期排队中：前方还有 ${Math.max(0, Number(msg.ahead || 0))} 个任务`
            : (msg.phase === 'queuedSummary' || msg.phase === 'summarizing')
              ? t('generatingInSummary')
            : (msg.phase === 'transcribing' && Number.isFinite(msg.transcribed) && Number.isFinite(msg.total) && Number(msg.total) > 0)
              ? tf('transcribingAudioSegments', { done: Math.max(0, Number(msg.transcribed)), total: Number(msg.total) })
            : msg.phase === 'transcribing'
              ? t('transcribingAudioFinalizing')
              : tf('transcribingAudioProgress', { percent });
session.audioProgressDetail = ['queuedSummary', 'summarizing'].includes(msg.phase)
            ? ''
            : t('audioUploadCaution');
          if (isActiveSession(session)) {
            slowAsrQueued = session.slowAsrQueued;
            updateAudioTranscriptionLoading(session.audioProgressMessage, session.audioProgressDetail);
            updateAudioSummaryActions();
          }
        }
        sendResponse({ ok: true });
        return;
      }
      if (msg && msg.type === 'audioSummaryJobCompleted') {
        void syncPendingSummaryResults().then(() => chrome.runtime.sendMessage({
          action: 'ackAudioSummaryJob', jobId: String(msg.jobId || ''),
        })).catch(() => {});
        showToast('🔔 音频总结已完成，已保存到总结历史', 'success');
        sendResponse({ ok: true });
        return;
      }
      if (msg && msg.type === 'kimiAudioPreparationProgress') {
        if (activeKimiJobId && msg.requestId === activeKimiJobId) {
          const percent = Math.max(0, Math.min(80, Math.round(Number(msg.percent || 0))));
          const message = msg.phase === 'compressing'
            ? '音频提取完成，正在进行1.5倍速压缩...'
            : '正在提取并上传视频音频...';
          void updateKimiSummaryStatus(activeKimiJobId, message, percent, false, false, KIMI_COMPRESSION_NOTICE);
        }
        sendResponse({ ok: true });
        return;
      }
      if (msg && (msg.type === 'dashboardLanguageChanged' || msg.type === 'dashboardSessionChanged')) {
        currentLanguage = normalizeLanguage(msg.language);
        document.documentElement.lang = currentLanguage;
        if (els.displayLanguage) els.displayLanguage.value = currentLanguage;
        applyLanguageToControls();
        if (msg.type === 'dashboardSessionChanged') void refreshAuthenticatedUi();
        sendResponse({ ok: true });
        return;
      }
      if (msg && msg.type === 'quotaRefresh') {
        if (msg.quota) {
          updateCreditBalanceDisplay(msg.quota.remaining);
          syncMembershipTierFromQuota(msg.quota);
        }
        sendResponse({ ok: true });
        return;
      }
      if (msg && msg.type === 'notificationsRead') {
        setNotificationBadge(msg.unreadCount);
        sendResponse({ ok: true });
        return;
      }
      if (msg && msg.type === 'closeSidePanel') {
        window.close();
        sendResponse({ ok: true });
      }
    });
    chrome.runtime.sendMessage({ action: 'serverQuota' }).then(async (qr) => {
      if (qr?.success && qr.quota) {
        updateCreditBalanceDisplay(qr.quota.remaining);
        await syncMembershipTierFromQuota(qr.quota);
      }
      else updateCreditBalanceDisplay(null);
    }).catch(() => updateCreditBalanceDisplay(null));
  }

  async function announceSidePanelState(open) {
    try {
      const tab = currentTab || await getActiveBilibiliTab();
      if (tab?.id) await chrome.tabs.sendMessage(tab.id, { action: 'setSidePanelOpenState', open });
    } catch (_) {}
  }

  async function syncSidePanelStateToActiveTab(tabId) {
    try {
      const tab = await chrome.tabs.get(tabId);
      if (/^https:\/\/www\.bilibili\.com\/(?:video\/|bangumi\/play\/|cheese\/play\/|list\/watchlater\/)/.test(tab?.url || '')) {
        await chrome.tabs.sendMessage(tabId, { action: 'setSidePanelOpenState', open: true });
      }
    } catch (_) {}
  }

  // ============ 初始化 ============
  init();
  initServer();
  setInterval(() => {
    if (document.visibilityState === 'visible') void refreshNotificationBadge();
  }, 60000);
  chrome.tabs.onActivated?.addListener(({ tabId }) => {
    void syncSidePanelStateToActiveTab(tabId);
    scheduleActiveTabDetection();
  });
  chrome.tabs.onUpdated?.addListener((tabId, changeInfo) => {
    if (tabId === currentTab?.id && (changeInfo.status === 'complete' || changeInfo.url)) scheduleActiveTabDetection();
  });
  chrome.tabs.onRemoved?.addListener((tabId) => {
    for (const session of videoSessions.values()) {
      if (session.tab?.id === tabId) session.tab = { ...session.tab, id: 0 };
    }
    renderHistoryList();
  });
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') {
      if (!isProcessing && !isAudioTranscribing) {
        updateStartButtonIdle();
        void refreshAuthenticatedUi();
        scheduleActiveTabDetection();
      }
    }
  });
  var tabSummary = document.getElementById('tab-summary');
  var tabSubtitle = document.getElementById('tab-subtitle');
  if (tabSummary) tabSummary.addEventListener('click', function() { switchResultMode('summary'); });
  if (tabSubtitle) tabSubtitle.addEventListener('click', function() {
    if (currentResultMode === 'subtitle') {
      // 已在字幕模式，点击切换字数显示
      showSubtitleCharCount = !showSubtitleCharCount;
      if (lastSubtitleText) renderSubtitleText(lastSubtitleText);
    } else {
      switchResultMode('subtitle');
    }
  });
  els.subtitleView.addEventListener('scroll', () => {
    if (currentResultMode === 'subtitle' && subtitleFollowEnabled && !subtitleProgrammaticScroll) stopSubtitleFollow();
  });
  els.resultContent.addEventListener('click', (event) => {
    const matchButton = event.target.closest('.subtitle-search-match');
    if (matchButton) {
      jumpToSubtitleSearchMatch(Number(matchButton.dataset.matchIndex), true, true);
      return;
    }
    const button = event.target.closest('.subtitle-segment');
    if (!button || !currentTab?.id) return;
    const segment = subtitleSegments[Number(button.dataset.subtitleIndex)];
    if (!segment) return;
    els.resultContent.querySelector('.subtitle-segment.active')?.classList.remove('active');
    els.resultContent.querySelectorAll('.subtitle-search-match').forEach((el) => el.classList.remove('active'));
    button.classList.add('active');
    chrome.tabs.sendMessage(currentTab.id, { action: 'seekVideo', time: segment.from })
      .then((response) => {
        if (response?.success) startSubtitleFollow();
      })
      .catch(() => {});
  });
  if (els.subtitleSearchBtn) {
    els.subtitleSearchBtn.addEventListener('click', () => setSubtitleSearchOpen(!subtitleSearchOpen));
  }
  if (els.subtitleLocateBtn) {
    els.subtitleLocateBtn.addEventListener('click', () => locateCurrentSubtitle());
  }
  if (els.resultFontSmallerBtn) {
    els.resultFontSmallerBtn.addEventListener('click', () => changeResultFontLevel(-1));
  }
  if (els.resultFontLargerBtn) {
    els.resultFontLargerBtn.addEventListener('click', () => changeResultFontLevel(1));
  }
  if (els.btnSubtitleSearchClose) {
    els.btnSubtitleSearchClose.addEventListener('click', () => setSubtitleSearchOpen(false));
  }
  if (els.subtitleSearchInput) {
    els.subtitleSearchInput.addEventListener('input', handleSubtitleSearchInput);
    els.subtitleSearchInput.addEventListener('keydown', (event) => {
      if (event.key !== 'Enter') return;
      event.preventDefault();
      const next = subtitleSearchMatches.length ? (subtitleSearchActiveIndex + 1) % subtitleSearchMatches.length : 0;
      jumpToSubtitleSearchMatch(next, true, true);
    });
  }
})();
