// ==UserScript==
// @name Bilibili Reader|哔哩哔哩阅读模式
// @version 0.0.2
// @description 将 B 站视频切换为视频、章节与字幕联动的阅读视图
// @author Local
// @homepageURL https://github.com/Wow-CaiCai/Bilibli-Reader
// @source https://github.com/Wow-CaiCai/Bilibli-Reader
// @supportURL https://github.com/Wow-CaiCai/Bilibli-Reader/issues
// @match https://www.bilibili.com/video/*
// @match https://www.bilibili.com/list/watchlater
// @match https://www.bilibili.com/list/watchlater/*
// @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAACY0lEQVR4nO2ZvW4TQRCAv3XARqEjDYQgQYdISYeQEBQgpUPKG/ASeYd0eYS00FIBEj+CnhIkxI/AliKRJiRyHMeXYnbu9s6XxNnbbHzkPml91v3MzuzM7O3NQkPD+cY4/1sl56aRxGkp0650GSb9sdwArgEXA3ZywR6HAWXuAV3gt3tyEfhI3j3T3D5YnTHADPAKeAi8BDbt+VyMebJsjy8CyFKd5oAl4A3wBKCDuLgXoJMiu7aFpgfsAx2N0RYwssdZJM6qJLaOlsq4RHWvJkh+7iC6GsiSzDgd9KmedEVl+2TGtD1lDpBR14HJGaCEiPujSKgeUjkdiwacJglwBVhBRnJmwueGiJ5ryPQ5RscK14shjNJwGdim3MZ/6nzgyOnac52YHjDAL+Ap+QQ/Dp1cPiNe2y/eEMsDIZYrqlvqgdYRN9eC2Ek8DzwnS8xJ2EPm/2fAl+LFmAYAXAbueT47R8k0HzuJvwM3OVkS6709RN/cSza2B4bAzwrPj+lb+ySOaUCChM8A2CabYo9rW/Z4l5I1Wu09EDuJf+C/GoWSJG484NHfdfyn0VGZwFgkwC3gq+fz95HCQ47YHtgGPuG3lPjLIZ+lzWr0LIkdQrPAY/w+aN4iL7Uxmk/KE7ABrOL3Uf+NrH6Vo9ZJXFT2tMvshmqFLZWRogZonBmkDBiqtKiEKC22yUqL6QaHGqCZPgL+VeikiCrcDyRvFxmINBd0dfcOeISU1w9943mgNZz1ALLc8vpV4DXOynQR2TQ4642LSdt74I5apSwgZY86bDH9sS3lv9jkM06bZtxQamg49xwAEzUXatUrVPQAAAAASUVORK5CYII=
// @run-at document-idle
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @grant unsafeWindow
// @connect api.bilibili.com
// @connect aisubtitle.hdslb.com
// @connect i0.hdslb.com
// @connect i1.hdslb.com
// @connect i2.hdslb.com
// @connect *.hdslb.com
// ==/UserScript==
(() => {
"use strict";
const USERSCRIPT_SETTINGS_KEY = "bilibli-reader-settings";
const runtimeListeners = [];
const storageListeners = [];
GM_addStyle("#viewbox_report.blr-page-reader-entry-host,\n.video-info-container.blr-page-reader-entry-host,\n.video-info-title.blr-page-reader-entry-host {\n position: relative !important;\n}\n\n#viewbox_report.blr-page-reader-entry-host h1.video-title,\n.video-info-container.blr-page-reader-entry-host h1.video-title {\n padding-right: 108px !important;\n box-sizing: border-box;\n}\n\n#blr-page-reader-entry {\n -webkit-appearance: none;\n appearance: none;\n position: absolute;\n top: auto;\n right: 2px;\n bottom: 4px;\n z-index: 20;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 6px;\n min-width: 82px;\n height: 34px;\n margin: 0;\n padding: 0 14px;\n box-sizing: border-box;\n border: 1px solid rgba(0, 174, 236, 0.42);\n border-radius: 9px;\n background: rgba(255, 255, 255, 0.96);\n box-shadow: 0 5px 16px rgba(0, 0, 0, 0.08);\n color: #18191c;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", sans-serif;\n font-size: 14px;\n font-weight: 600;\n line-height: 1;\n cursor: pointer;\n transition: background-color 0.16s ease, border-color 0.16s ease, color 0.16s ease,\n transform 0.16s ease, box-shadow 0.16s ease;\n}\n\n#blr-page-reader-entry svg {\n width: 17px;\n height: 17px;\n flex: 0 0 auto;\n fill: none;\n stroke: currentColor;\n stroke-width: 1.8;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n#blr-page-reader-entry:hover {\n border-color: #00aeec;\n background: #00aeec;\n color: #fff;\n box-shadow: 0 7px 20px rgba(0, 174, 236, 0.22);\n transform: translateY(-1px);\n}\n\n#blr-page-reader-entry:active {\n transform: translateY(0);\n}\n\n#blr-page-reader-entry:focus-visible {\n outline: 2px solid rgba(0, 174, 236, 0.42);\n outline-offset: 2px;\n}\n\n#blr-page-reader-entry:disabled,\n#blr-page-reader-entry.is-loading {\n cursor: wait;\n opacity: 0.68;\n transform: none;\n}\n\nhtml[data-blr-reader-mode=\"1\"] #blr-page-reader-entry,\nbody[data-blr-reader-mode=\"1\"] #blr-page-reader-entry {\n display: none !important;\n}\n\n@media (max-width: 760px) {\n #viewbox_report.blr-page-reader-entry-host h1.video-title,\n .video-info-container.blr-page-reader-entry-host h1.video-title {\n padding-right: 0 !important;\n }\n\n #blr-page-reader-entry {\n position: relative;\n top: auto;\n right: auto;\n bottom: auto;\n margin-top: 8px;\n }\n}\n\n#blr-panel {\n position: fixed;\n right: 16px;\n top: 72px;\n width: min(420px, calc(100vw - 24px));\n height: min(820px, calc(100vh - 88px));\n z-index: 2147483647;\n background: #f1f1f1;\n border: 1px solid #d8d8d8;\n border-radius: 14px;\n box-shadow: 0 24px 46px rgba(0, 0, 0, 0.24);\n padding: 12px;\n box-sizing: border-box;\n display: none;\n flex-direction: column;\n gap: 8px;\n font-family: -apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"PingFang SC\", \"Helvetica Neue\",\n Arial, sans-serif;\n}\n\n#blr-panel.open {\n display: flex;\n}\n\n.blr-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 0 4px;\n}\n\n.blr-header strong {\n font-size: 17px;\n font-weight: 600;\n letter-spacing: 0.01em;\n color: #3d3d3d;\n}\n\n.blr-header-actions {\n display: flex;\n gap: 6px;\n}\n\n.blr-header button {\n border: 1px solid #cfcfcf;\n background: #f8f8f8;\n border-radius: 10px;\n padding: 7px 10px;\n cursor: pointer;\n color: #4a4a4a;\n}\n\n.blr-status {\n margin: 0;\n font-size: 12px;\n color: #555;\n background: #e8e8e8;\n border-radius: 8px;\n padding: 7px 8px;\n}\n\n.blr-props-head {\n font-size: 15px;\n font-weight: 600;\n color: #4a4a4a;\n margin-top: 0;\n}\n\n.blr-meta {\n border: 1px solid #d5d5d5;\n border-radius: 10px;\n padding: 8px 10px;\n background: #f7f7f7;\n display: grid;\n gap: 6px;\n font-size: 13px;\n color: #3f3f3f;\n}\n\n.blr-meta-item {\n word-break: break-word;\n}\n\n.blr-label {\n font-size: 12px;\n font-weight: 600;\n color: #4d4d4d;\n margin-top: 2px;\n}\n\n#blr-subtitle-select {\n height: 36px;\n border: 1px solid #cfcfcf;\n border-radius: 8px;\n padding: 0 10px;\n background: #fff;\n color: #2d2d2d;\n}\n\n.blr-player-ai-wrap {\n align-items: center;\n background: rgba(20, 24, 32, 0.68);\n border: 1px solid rgba(255, 255, 255, 0.18);\n border-radius: 999px;\n box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22);\n display: inline-flex;\n flex: 0 0 var(--blr-player-ai-action-hit-size, 28px);\n height: var(--blr-player-ai-action-hit-size, 28px);\n justify-content: center;\n margin: 0;\n min-width: var(--blr-player-ai-action-hit-size, 28px);\n opacity: 0;\n overflow: hidden;\n pointer-events: none;\n position: absolute;\n right: 14px;\n top: 50%;\n transform: translate(8px, -50%);\n transition: opacity 0.18s ease, background-color 0.18s ease, transform 0.18s ease;\n width: var(--blr-player-ai-action-hit-size, 28px);\n z-index: 30;\n}\n\n.blr-player-ai-wrap.is-active,\n.blr-player-ai-wrap:hover,\n.blr-player-ai-wrap:focus-within {\n background: rgba(20, 24, 32, 0.78);\n opacity: 1;\n pointer-events: auto;\n transform: translate(0, -50%);\n}\n\n#blr-player-ai-quick-action {\n -webkit-appearance: none;\n appearance: none;\n align-items: center;\n align-self: center;\n background: transparent;\n border: 0;\n border-radius: 0;\n box-sizing: border-box;\n color: var(--blr-player-ai-action-color, currentColor);\n display: inline-flex;\n flex: 0 0 auto;\n justify-content: center;\n width: 100%;\n height: 100%;\n cursor: pointer;\n line-height: 0;\n margin-bottom: 0;\n margin-top: 0;\n min-width: 0;\n overflow: hidden;\n padding: 0;\n position: relative;\n top: 0;\n vertical-align: top;\n transition: transform 0.16s ease, color 0.16s ease, opacity 0.16s ease;\n}\n\n#blr-player-ai-quick-action svg {\n width: var(--blr-player-ai-action-icon-size, 24px);\n height: var(--blr-player-ai-action-icon-size, 24px);\n display: block;\n flex: 0 0 auto;\n stroke: currentColor;\n fill: none;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n#blr-player-ai-quick-action:hover {\n color: var(--blr-player-ai-action-hover-color, var(--blr-player-ai-action-color, currentColor));\n}\n\n#blr-player-ai-quick-action:disabled {\n opacity: 0.62;\n cursor: wait;\n transform: none;\n}\n\n.blr-confirm-overlay {\n align-items: center;\n background: rgba(17, 24, 39, 0.28);\n display: flex;\n inset: 0;\n justify-content: center;\n padding: 18px;\n position: fixed;\n z-index: 2147483647;\n}\n\n.blr-confirm-dialog {\n background: #ffffff;\n border: 1px solid #e5e7eb;\n border-radius: 16px;\n box-shadow: 0 20px 60px rgba(15, 23, 42, 0.22);\n color: #111827;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n max-width: 360px;\n padding: 18px;\n width: calc(100vw - 36px);\n}\n\n.blr-confirm-title {\n font-size: 15px;\n font-weight: 700;\n margin-bottom: 8px;\n}\n\n.blr-confirm-body {\n color: #4b5563;\n font-size: 13px;\n margin-bottom: 8px;\n}\n\n.blr-confirm-path {\n background: #f7f8fb;\n border: 1px solid #edf0f4;\n border-radius: 10px;\n color: #374151;\n font-size: 12px;\n line-height: 1.45;\n max-height: 96px;\n overflow: auto;\n padding: 9px 10px;\n word-break: break-all;\n}\n\n.blr-confirm-actions {\n display: flex;\n gap: 8px;\n justify-content: flex-end;\n margin-top: 14px;\n}\n\n.blr-confirm-actions button {\n border: 1px solid #d9dee7;\n border-radius: 10px;\n cursor: pointer;\n font-size: 13px;\n height: 34px;\n padding: 0 14px;\n}\n\n.blr-confirm-cancel {\n background: #ffffff;\n color: #374151;\n}\n\n.blr-confirm-primary {\n background: #111827;\n border-color: #111827 !important;\n color: #ffffff;\n}\n\n#blr-preview {\n width: 100%;\n flex: 1;\n min-height: 180px;\n box-sizing: border-box;\n border: 1px solid #d0d0d0;\n border-radius: 10px;\n padding: 10px;\n resize: vertical;\n font-size: 13px;\n line-height: 1.55;\n background: #fff;\n color: #2f2f2f;\n font-family: \"SF Mono\", \"JetBrains Mono\", \"PingFang SC\", Menlo, Monaco, Consolas, monospace;\n}\n\n.blr-actions {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 8px;\n margin-top: 0;\n}\n\n.blr-actions button {\n border: 1px solid #cfcfcf;\n background: #f8f8f8;\n border-radius: 9px;\n padding: 8px 8px;\n cursor: pointer;\n color: #3d3d3d;\n font-size: 13px;\n}\n\n#blr-send-btn {\n grid-column: span 2;\n border: 1px solid #a88fff;\n background: linear-gradient(180deg, #9c82ff 0%, #8f75ef 100%);\n color: #fff;\n font-weight: 600;\n}\n\n.blr-actions button:disabled {\n cursor: not-allowed;\n opacity: 0.55;\n}\n\n.blr-message {\n margin: 0;\n font-size: 12px;\n color: #0f766e;\n min-height: 18px;\n padding: 0 2px;\n}\n\n#blr-reading-view {\n position: fixed;\n left: 16px;\n right: 16px;\n bottom: 16px;\n height: min(76vh, 860px);\n z-index: 2147483646;\n display: none;\n padding: 20px 22px;\n box-sizing: border-box;\n background: #fff;\n border: 1px solid #e5e7eb;\n border-radius: 18px;\n box-shadow: 0 28px 60px rgba(15, 23, 42, 0.16);\n font-family: -apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"PingFang SC\", \"Helvetica Neue\",\n Arial, sans-serif;\n}\n\n#blr-reading-view.open {\n display: block;\n}\n\n#blr-reading-view[data-blr-reader-ready=\"0\"] {\n opacity: 0;\n visibility: hidden;\n pointer-events: none;\n}\n\n.blr-reading-layout {\n min-height: 0;\n height: 100%;\n display: grid;\n grid-template-columns: 240px minmax(0, 1fr);\n gap: 28px;\n}\n\n.blr-reading-rail {\n min-height: 0;\n display: grid;\n grid-template-rows: auto auto minmax(0, 1fr);\n gap: 10px;\n align-content: start;\n}\n\n.blr-reading-stage {\n min-height: 0;\n display: grid;\n grid-template-rows: auto auto minmax(320px, 1fr) minmax(0, 1fr);\n gap: 14px;\n}\n\n.blr-reading-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 18px;\n}\n\n.blr-reading-header-copy {\n min-width: 0;\n}\n\n.blr-reading-eyebrow {\n font-size: 11px;\n font-weight: 700;\n letter-spacing: 0.08em;\n color: #9ca3af;\n text-transform: uppercase;\n}\n\n.blr-reading-title {\n display: block;\n margin: 0;\n font-size: 16px;\n line-height: 1.15;\n color: #0f172a;\n word-break: break-word;\n}\n\n.blr-reading-meta,\n.blr-reading-status {\n font-size: 14px;\n line-height: 1.5;\n color: #6b7280;\n}\n\n.blr-reading-meta {\n margin-top: 8px;\n}\n\n.blr-reading-actions {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n justify-content: flex-end;\n transition: opacity 0.2s ease;\n}\n\n.blr-reading-actions[data-blr-icon-hidden=\"1\"] {\n opacity: 0;\n pointer-events: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .blr-reading-actions {\n transition: none;\n }\n}\n\n.blr-reading-actions button,\n.blr-reading-toggle {\n border: 1px solid #e5e7eb;\n background: #fff;\n border-radius: 999px;\n min-height: 36px;\n padding: 0 14px;\n font-size: 13px;\n color: #1f2937;\n box-sizing: border-box;\n}\n\n.blr-reading-actions button {\n cursor: pointer;\n}\n\n.blr-reading-toggle {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n}\n\n.blr-reading-toggle input {\n margin: 0;\n}\n\n.blr-reading-icon-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 34px;\n height: 34px;\n border: 1px solid #e5e7eb;\n background: #fff;\n border-radius: 8px;\n cursor: pointer;\n padding: 0;\n color: #1f2937;\n}\n\n.blr-reading-icon-btn svg {\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n}\n\n.blr-reading-icon-btn:hover {\n background: #f3f4f6;\n border-color: #d1d5db;\n}\n\n.blr-reading-icon-btn.is-active {\n background: #eff6ff;\n border-color: #2563eb;\n color: #2563eb;\n}\n\n.blr-reading-panel-row {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.blr-reading-inline-label {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n font-size: 12px;\n color: #6b7280;\n}\n\n.blr-reading-inline-label span {\n white-space: nowrap;\n}\n\n.blr-reading-select-sm {\n min-height: 28px;\n padding: 0 6px;\n font-size: 12px;\n border-radius: 6px;\n border: 1px solid #e5e7eb;\n background: #fff;\n color: #1f2937;\n cursor: pointer;\n}\n\n.blr-reading-select-sm:focus {\n outline: none;\n border-color: #2563eb;\n box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);\n}\n\n.blr-reading-list,\n.blr-reading-transcript {\n min-height: 0;\n overflow: auto;\n}\n\n.blr-reading-list {\n padding-right: 6px;\n}\n\n.blr-reading-player-shell {\n min-height: 0;\n display: flex;\n justify-content: center;\n align-items: flex-start;\n pointer-events: none;\n}\n\n.blr-reading-player-slot {\n width: min(100%, 960px);\n aspect-ratio: 16 / 9;\n max-height: 100%;\n min-height: 0;\n overflow: hidden;\n background: #fff;\n display: flex;\n align-items: center;\n justify-content: center;\n pointer-events: none;\n}\n\n.blr-reading-player-slot > * {\n width: 100% !important;\n max-width: 100% !important;\n height: 100% !important;\n}\n\n.blr-reading-player-slot > video {\n width: 100% !important;\n height: 100% !important;\n display: block;\n background: #000;\n object-fit: contain;\n}\n\n.blr-reading-player-slot #bilibili-player,\n.blr-reading-player-slot .bpx-player-container,\n.blr-reading-player-slot .bpx-player-video-area,\n.blr-reading-player-slot .bpx-player-primary-area,\n.blr-reading-player-slot .bpx-player-inner {\n width: 100% !important;\n height: 100% !important;\n}\n\n.blr-reading-main {\n min-height: 0;\n overflow: hidden;\n display: flex;\n justify-content: center;\n pointer-events: auto;\n}\n\n.blr-reading-transcript {\n width: min(100%, 960px);\n height: 100%;\n max-height: 100%;\n margin: 0 auto;\n padding: 6px 8px 12px;\n overflow-x: hidden;\n overflow-y: auto;\n pointer-events: auto;\n}\n\n.blr-reading-chapter,\n.blr-reading-item {\n width: 100%;\n border: 0;\n background: transparent;\n border-radius: 14px;\n text-align: left;\n cursor: pointer;\n user-select: text;\n -webkit-user-select: text;\n}\n\n.blr-reading-chapter {\n display: grid;\n gap: 4px;\n padding: 8px 10px;\n color: #4b5563;\n}\n\n.blr-reading-item {\n display: grid;\n grid-template-columns: 64px minmax(0, 1fr);\n gap: 10px;\n align-items: start;\n padding: 6px 8px;\n color: #111827;\n position: relative;\n}\n\n.blr-reading-chapter:hover {\n background: #f9fafb;\n}\n\n.blr-reading-chapter.is-active,\n.blr-reading-item.is-active {\n background: transparent;\n box-shadow: none;\n}\n\n.blr-reading-chapter-time,\n.blr-reading-time {\n font-size: var(--blr-reader-transcript-font-size);\n font-weight: 600;\n color: #16a34a;\n padding-top: 2px;\n line-height: 1.4;\n}\n\n.blr-reading-chapter-title {\n font-size: 13px;\n line-height: 1.45;\n color: inherit;\n}\n\n.blr-reading-chapter.is-active .blr-reading-chapter-title,\n.blr-reading-item.is-active .blr-reading-text {\n text-decoration-line: underline;\n text-decoration-color: #22c55e;\n text-decoration-thickness: 2px;\n text-underline-offset: 3px;\n}\n\n.blr-reading-text {\n font-size: 12px;\n line-height: 1.5;\n color: inherit;\n}\n\n.blr-reading-empty {\n padding: 12px 4px;\n font-size: 14px;\n color: #94a3b8;\n}\n\n@media (max-width: 1100px) {\n #blr-reading-view {\n left: 12px;\n right: 12px;\n bottom: 12px;\n padding: 18px;\n }\n\n .blr-reading-layout {\n grid-template-columns: 210px minmax(0, 1fr);\n gap: 20px;\n }\n\n .blr-reading-title {\n font-size: 16px;\n }\n}\n\n@media (max-width: 760px) {\n #blr-reading-view {\n top: 72px;\n height: auto;\n }\n\n .blr-reading-layout {\n grid-template-columns: 1fr;\n gap: 18px;\n }\n\n .blr-reading-stage {\n grid-template-rows: auto auto minmax(220px, auto) minmax(0, 1fr);\n }\n\n .blr-reading-header {\n flex-direction: column;\n align-items: stretch;\n }\n\n .blr-reading-actions {\n justify-content: flex-start;\n }\n\n .blr-reading-title {\n font-size: 16px;\n }\n\n .blr-reading-item {\n grid-template-columns: 56px minmax(0, 1fr);\n gap: 10px;\n }\n\n .blr-reading-text {\n font-size: 13px;\n }\n}\n\nhtml[data-blr-reader-mode=\"1\"],\nbody[data-blr-reader-mode=\"1\"] {\n margin: 0 !important;\n padding: 0 !important;\n width: 100% !important;\n min-height: 100% !important;\n background: #fff !important;\n overflow-x: hidden !important;\n overflow-y: auto !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-root {\n position: absolute;\n inset: 0;\n z-index: 2147483646;\n pointer-events: none;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-panel {\n display: none !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-view {\n position: absolute;\n inset: 0;\n width: auto;\n max-width: none;\n height: 100vh;\n display: none;\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n border: 0;\n border-radius: 0;\n box-shadow: none;\n background: transparent;\n pointer-events: none;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-view.open {\n display: block;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-view[data-blr-reader-ready=\"0\"] {\n opacity: 0;\n visibility: hidden;\n pointer-events: none;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-header-copy,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-status {\n display: none !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] #viewbox_report,\nbody[data-blr-reader-mode=\"1\"] .video-info-container,\nbody[data-blr-reader-mode=\"1\"] .video-info-title,\nbody[data-blr-reader-mode=\"1\"] .video-info-title-inner {\n margin: 0 !important;\n padding: 0 !important;\n row-gap: 0 !important;\n column-gap: 0 !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] #viewbox_report,\nbody[data-blr-reader-mode=\"1\"] .video-info-container {\n margin-bottom: 4px !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] .left-container,\nbody[data-blr-reader-mode=\"1\"] .scroll-sticky {\n row-gap: 8px !important;\n padding-top: 0 !important;\n overflow: visible !important;\n transition: none !important;\n animation: none !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] #playerWrap,\nbody[data-blr-reader-mode=\"1\"] .player-wrap,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-rail {\n transition: none !important;\n animation: none !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] .video-info-detail,\nbody[data-blr-reader-mode=\"1\"] .video-info-meta,\nbody[data-blr-reader-mode=\"1\"] .video-data {\n display: none !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-view[data-blr-reader-follow=\"manual\"] .blr-reading-main,\nbody[data-blr-reader-mode=\"1\"] #blr-reading-view[data-blr-reader-follow=\"manual\"] .blr-reading-rail {\n border-color: rgba(148, 163, 184, 0.8);\n box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-view[data-blr-reader-follow=\"manual\"] .blr-reading-eyebrow::after {\n content: \"手动浏览中\";\n display: inline-block;\n margin-left: 8px;\n font-size: 11px;\n font-weight: 500;\n color: #94a3b8;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reader-player-host {\n background: #000 !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] #playerWrap {\n position: static !important;\n top: auto !important;\n z-index: auto !important;\n background: transparent !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] .player-wrap,\nbody[data-blr-reader-mode=\"1\"] .scroll-sticky {\n position: static !important;\n top: auto !important;\n z-index: auto !important;\n background: transparent !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] [data-blr-reader-player-reset=\"1\"] {\n position: static !important;\n inset: auto !important;\n width: auto !important;\n height: auto !important;\n transform: none !important;\n margin: 0 !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] .strip-ad-inner,\nbody[data-blr-reader-mode=\"1\"] .inside-wrp,\nbody[data-blr-reader-mode=\"1\"] .inside-bg,\nbody[data-blr-reader-mode=\"1\"] .hinter-msg,\nbody[data-blr-reader-mode=\"1\"] .slide,\nbody[data-blr-reader-mode=\"1\"] .cover.b-img,\nbody[data-blr-reader-mode=\"1\"] .cover.b-img.sleepy,\nbody[data-blr-reader-mode=\"1\"] .b-img.clickable {\n display: none !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] [data-blr-reader-hidden=\"1\"] {\n display: none !important;\n}\n\nhtml[data-blr-reader-mode=\"1\"],\nbody[data-blr-reader-mode=\"1\"],\n#blr-reading-view {\n --blr-reader-page-bg: #f4f6f8;\n --blr-reader-surface: #ffffff;\n --blr-reader-surface-2: #f7f9fc;\n --blr-reader-surface-3: #edf2f7;\n --blr-reader-border: rgba(15, 23, 42, 0.1);\n --blr-reader-text: #0f172a;\n --blr-reader-muted: #64748b;\n --blr-reader-accent: #16a34a;\n --blr-reader-accent-soft: rgba(34, 197, 94, 0.12);\n --blr-reader-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);\n --blr-reader-transcript-font-size: 14px;\n --blr-reader-transcript-font-weight: 500;\n --blr-reader-transcript-line-height: 1.5;\n --blr-reader-letter-spacing: 0em;\n --blr-reader-content-max: 980px;\n color: var(--blr-reader-text);\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-theme=\"dark\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-theme=\"dark\"],\n#blr-reading-view[data-theme=\"dark\"] {\n --blr-reader-page-bg: #09111f;\n --blr-reader-surface: #0f172a;\n --blr-reader-surface-2: #132038;\n --blr-reader-surface-3: #1a2a46;\n --blr-reader-border: rgba(148, 163, 184, 0.22);\n --blr-reader-text: #e5eefb;\n --blr-reader-muted: #9fb0c8;\n --blr-reader-accent: #4ade80;\n --blr-reader-accent-soft: rgba(74, 222, 128, 0.14);\n --blr-reader-shadow: 0 18px 42px rgba(2, 6, 23, 0.42);\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-theme=\"paper\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-theme=\"paper\"],\n#blr-reading-view[data-theme=\"paper\"] {\n --blr-reader-page-bg: #f4eddc;\n --blr-reader-surface: #f4eddc;\n --blr-reader-surface-2: #f4eddc;\n --blr-reader-surface-3: #f4eddc;\n --blr-reader-border: rgba(180, 155, 112, 0.22);\n --blr-reader-text: #3b3124;\n --blr-reader-muted: #756653;\n --blr-reader-accent: #2f9e44;\n --blr-reader-accent-soft: rgba(47, 158, 68, 0.12);\n --blr-reader-shadow: 0 18px 40px rgba(112, 93, 64, 0.12);\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-font-scale=\"s\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-font-scale=\"s\"],\n#blr-reading-view[data-font-scale=\"s\"] {\n --blr-reader-transcript-font-size: 12.8px;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-font-scale=\"xs\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-font-scale=\"xs\"],\n#blr-reading-view[data-font-scale=\"xs\"] {\n --blr-reader-transcript-font-size: 11.6px;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-font-scale=\"l\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-font-scale=\"l\"],\n#blr-reading-view[data-font-scale=\"l\"] {\n --blr-reader-transcript-font-size: 16.4px;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-font-scale=\"xl\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-font-scale=\"xl\"],\n#blr-reading-view[data-font-scale=\"xl\"] {\n --blr-reader-transcript-font-size: 18.8px;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-font-weight=\"light\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-font-weight=\"light\"],\n#blr-reading-view[data-font-weight=\"light\"] {\n --blr-reader-transcript-font-weight: 300;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-font-weight=\"regular\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-font-weight=\"regular\"],\n#blr-reading-view[data-font-weight=\"regular\"] {\n --blr-reader-transcript-font-weight: 400;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-font-weight=\"semibold\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-font-weight=\"semibold\"],\n#blr-reading-view[data-font-weight=\"semibold\"] {\n --blr-reader-transcript-font-weight: 600;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-font-weight=\"bold\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-font-weight=\"bold\"],\n#blr-reading-view[data-font-weight=\"bold\"] {\n --blr-reader-transcript-font-weight: 700;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-letter-spacing=\"tighter\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-letter-spacing=\"tighter\"],\n#blr-reading-view[data-letter-spacing=\"tighter\"] {\n --blr-reader-letter-spacing: -0.072em;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-letter-spacing=\"tight\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-letter-spacing=\"tight\"],\n#blr-reading-view[data-letter-spacing=\"tight\"] {\n --blr-reader-letter-spacing: -0.036em;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-letter-spacing=\"relaxed\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-letter-spacing=\"relaxed\"],\n#blr-reading-view[data-letter-spacing=\"relaxed\"] {\n --blr-reader-letter-spacing: 0.036em;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-letter-spacing=\"loose\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-letter-spacing=\"loose\"],\n#blr-reading-view[data-letter-spacing=\"loose\"] {\n --blr-reader-letter-spacing: 0.072em;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-content-width=\"compact\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-content-width=\"compact\"],\n#blr-reading-view[data-content-width=\"compact\"] {\n --blr-reader-content-max: 720px;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-content-width=\"narrow\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-content-width=\"narrow\"],\n#blr-reading-view[data-content-width=\"narrow\"] {\n --blr-reader-content-max: 820px;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-content-width=\"wide\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-content-width=\"wide\"],\n#blr-reading-view[data-content-width=\"wide\"] {\n --blr-reader-content-max: 1120px;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-content-width=\"full\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-content-width=\"full\"],\n#blr-reading-view[data-content-width=\"full\"] {\n --blr-reader-content-max: 1280px;\n}\n\nhtml[data-blr-reader-mode=\"1\"],\nbody[data-blr-reader-mode=\"1\"],\nbody[data-blr-reader-mode=\"1\"] #app,\nbody[data-blr-reader-mode=\"1\"] .left-container,\nbody[data-blr-reader-mode=\"1\"] .right-container,\nbody[data-blr-reader-mode=\"1\"] .right-container-inner,\nbody[data-blr-reader-mode=\"1\"] .video-info-container,\nbody[data-blr-reader-mode=\"1\"] .video-info-detail,\nbody[data-blr-reader-mode=\"1\"] .video-sections-content-list,\nbody[data-blr-reader-mode=\"1\"] .video-container-v1,\nbody[data-blr-reader-mode=\"1\"] .video-container-v3 {\n background: var(--blr-reader-page-bg) !important;\n color: var(--blr-reader-text);\n}\n\nhtml[data-blr-reader-mode=\"1\"],\nbody[data-blr-reader-mode=\"1\"],\nbody[data-blr-reader-mode=\"1\"] #app {\n min-height: 100vh !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] #app {\n min-width: 0 !important;\n width: 100% !important;\n max-width: none !important;\n}\n\n#blr-reading-view,\n#blr-reading-view .blr-reading-meta,\n#blr-reading-view .blr-reading-status,\n#blr-reading-view .blr-reading-chapter,\n#blr-reading-view .blr-reading-item,\n#blr-reading-view .blr-reading-panel,\n#blr-reading-view .blr-reading-select {\n color: var(--blr-reader-text);\n}\n\n#blr-reading-view .blr-reading-layout {\n gap: 22px;\n}\n\n#blr-reading-view .blr-reading-stage {\n display: grid;\n gap: 16px;\n}\n\n#blr-reading-view .blr-reading-header {\n align-items: flex-start;\n}\n\n#blr-reading-view .blr-reading-title {\n font-size: 20px;\n color: var(--blr-reader-text);\n margin-top: 10px;\n}\n\n#blr-reading-view .blr-reading-meta,\n#blr-reading-view .blr-reading-status,\n#blr-reading-view .blr-reading-panel-grid label,\n#blr-reading-view .blr-reading-info-label {\n font-size: 14px;\n color: var(--blr-reader-muted);\n}\n\n#blr-reading-view .blr-reading-actions {\n gap: 10px;\n}\n\n#blr-reading-view .blr-reading-actions button,\n#blr-reading-view .blr-reading-toggle,\n#blr-reading-view .blr-reading-select {\n border: 1px solid var(--blr-reader-border);\n background: var(--blr-reader-surface);\n color: var(--blr-reader-text);\n box-shadow: none;\n}\n\n#blr-reading-view .blr-reading-actions button.is-active,\n#blr-reading-view .blr-reading-toggle.is-active,\n#blr-reading-view .blr-reading-select:focus {\n background: var(--blr-reader-accent-soft);\n color: var(--blr-reader-accent);\n}\n\n#blr-reading-view .blr-reading-select {\n min-height: 36px;\n border-radius: 999px;\n padding: 0 12px;\n width: auto;\n margin: 0;\n}\n\n#blr-reading-view .blr-reading-panel {\n border-radius: 18px;\n padding: 14px 16px;\n pointer-events: auto;\n background: var(--blr-reader-surface);\n border: 1px solid var(--blr-reader-border);\n box-shadow: var(--blr-reader-shadow);\n}\n\n#blr-reading-view .blr-reading-panel-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 12px;\n}\n\n#blr-reading-view .blr-reading-panel-grid label,\n#blr-reading-view .blr-reading-info-group {\n display: grid;\n gap: 6px;\n font-size: 13px;\n}\n\n#blr-reading-view .blr-reading-info-panel,\n#blr-reading-view .blr-reading-info-list {\n display: grid;\n gap: 6px;\n}\n\n#blr-reading-view .blr-reading-info-panel {\n gap: 16px;\n}\n\n#blr-reading-view .blr-reading-info-item {\n display: grid;\n grid-template-columns: auto minmax(0, 1fr);\n gap: 12px;\n align-items: start;\n}\n\n#blr-reading-view .blr-reading-info-value,\n#blr-reading-view .blr-reading-info-copy {\n font-size: 14px;\n line-height: 1.45;\n color: var(--blr-reader-text);\n word-break: break-word;\n}\n\n#blr-reading-view .blr-reading-control-field {\n display: grid;\n gap: 4px;\n font-size: 13px;\n color: var(--blr-reader-muted);\n}\n\n#blr-reading-view .blr-reading-text-btn {\n border: 0;\n background: transparent;\n color: var(--blr-reader-accent);\n padding: 0;\n cursor: pointer;\n width: fit-content;\n font-size: 13px;\n}\n\n#blr-reading-view .blr-reading-main {\n justify-content: center;\n background: transparent;\n}\n\n#blr-reading-view .blr-reading-transcript {\n width: min(100%, var(--blr-reader-content-max));\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-transcript-visible=\"0\"] #blr-reading-view .blr-reading-main,\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-transcript-visible=\"0\"] #blr-reading-view .blr-reading-main,\n#blr-reading-view[data-transcript-visible=\"0\"] .blr-reading-main {\n display: none !important;\n}\n\n#blr-reading-view[data-transcript-visible=\"0\"] #blr-reading-inline-host {\n border: none;\n background: transparent;\n}\n\n#blr-reading-view .blr-reading-item {\n grid-template-columns: 70px minmax(0, 1fr);\n gap: 12px;\n padding: 10px 12px;\n border-radius: 18px;\n background: transparent;\n}\n\n#blr-reading-view .blr-reading-chapter:hover {\n background: var(--blr-reader-surface-2);\n}\n\n#blr-reading-view .blr-reading-item.is-active,\n#blr-reading-view .blr-reading-chapter.is-active {\n background: var(--blr-reader-accent-soft);\n}\n\n#blr-reading-view .blr-reading-time,\n#blr-reading-view .blr-reading-chapter-time {\n color: var(--blr-reader-accent);\n}\n\n#blr-reading-view .blr-reading-text {\n font-size: var(--blr-reader-transcript-font-size);\n font-weight: var(--blr-reader-transcript-font-weight);\n line-height: var(--blr-reader-transcript-line-height);\n letter-spacing: var(--blr-reader-letter-spacing);\n color: var(--blr-reader-text);\n}\n\nbody[data-blr-reading-active=\"1\"] .blr-reader-player-host .bpx-player-subtitle-wrap,\nbody[data-blr-reading-active=\"1\"] .blr-reader-player-host .bpx-player-subtitle-panel,\nbody[data-blr-reading-active=\"1\"] .blr-reader-player-host .bpx-player-subtitle-container,\nbody[data-blr-reading-active=\"1\"] .blr-reader-player-host .bilibili-player-video-subtitle,\nbody[data-blr-reading-active=\"1\"] .blr-reader-player-host .subtitle-wrap,\nbody[data-blr-reading-active=\"1\"] .blr-reader-player-host [class*=\"subtitle\"],\nbody[data-blr-reading-active=\"1\"] .blr-reader-player-host .bpx-player-sending-bar,\nbody[data-blr-reading-active=\"1\"] .bpx-player-sending-bar {\n display: none !important;\n}\n\nbody[data-blr-reading-active=\"1\"] [data-blr-reader-fading] {\n visibility: hidden !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-view {\n background: transparent;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-header {\n left: auto;\n right: 24px;\n max-width: min(1100px, calc(100vw - 48px));\n gap: 14px;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-actions {\n pointer-events: auto;\n justify-content: flex-end;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-panel {\n position: fixed;\n right: 24px;\n top: 76px;\n width: min(380px, calc(100vw - 48px));\n z-index: 2147483646;\n background: var(--blr-reader-surface-2);\n}\n\n/* Simplified reader layout: player in normal flow, transcript box below with its own scroll. */\nbody[data-blr-reader-mode=\"1\"] .blr-reading-player-shell {\n display: none !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-layout,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-stage {\n display: block;\n height: auto;\n}\n\nhtml[data-blr-reader-mode=\"1\"],\nbody[data-blr-reader-mode=\"1\"] {\n --blr-reader-page-padding: clamp(16px, 2.8vw, 32px);\n --blr-reader-layout-gap: clamp(16px, 2vw, 24px);\n --blr-reader-rail-target-width: clamp(168px, 15vw, 220px);\n --blr-reader-main-width: min(\n var(--blr-reader-content-max),\n calc(100vw - (var(--blr-reader-page-padding) * 2))\n );\n --blr-reader-rail-width: min(\n var(--blr-reader-rail-target-width),\n max(\n 0px,\n calc(\n ((100vw - var(--blr-reader-main-width)) / 2) - var(--blr-reader-page-padding) - var(--blr-reader-layout-gap)\n )\n )\n );\n --blr-reader-transcript-line-height: 1.404;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-line-height=\"compact\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-line-height=\"compact\"],\n#blr-reading-view[data-line-height=\"compact\"] {\n --blr-reader-transcript-line-height: 1.32;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-line-height=\"tight\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-line-height=\"tight\"],\n#blr-reading-view[data-line-height=\"tight\"] {\n --blr-reader-transcript-line-height: 1.404;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-line-height=\"normal\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-line-height=\"normal\"],\n#blr-reading-view[data-line-height=\"normal\"] {\n --blr-reader-transcript-line-height: 1.5;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-line-height=\"relaxed\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-line-height=\"relaxed\"],\n#blr-reading-view[data-line-height=\"relaxed\"] {\n --blr-reader-transcript-line-height: 1.596;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-line-height=\"loose\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-line-height=\"loose\"],\n#blr-reading-view[data-line-height=\"loose\"] {\n --blr-reader-transcript-line-height: 1.692;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-actions {\n gap: 12px;\n margin-right: -20px;\n margin-top: -8px;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-header {\n position: fixed;\n top: 18px;\n right: var(--blr-reader-page-padding);\n left: auto;\n max-width: calc(100vw - (var(--blr-reader-page-padding) * 2));\n margin-bottom: 0;\n z-index: 2147483646;\n pointer-events: auto;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-panel {\n position: fixed;\n right: var(--blr-reader-page-padding);\n top: 72px;\n width: min(286px, calc(100vw - (var(--blr-reader-page-padding) * 2)));\n max-height: calc(100vh - 96px);\n overflow: auto;\n display: grid;\n gap: 10px;\n background: var(--blr-reader-surface-2);\n}\n\n.blr-reading-stepper-list {\n display: grid;\n gap: 7px;\n}\n\n.blr-reading-stepper {\n align-items: center;\n display: grid;\n grid-template-columns: 56px minmax(0, 1fr);\n gap: 8px;\n}\n\n.blr-reading-stepper-title {\n min-width: 0;\n color: var(--blr-reader-text);\n font-size: 12px;\n font-weight: 600;\n white-space: nowrap;\n}\n\n.blr-reading-stepper-buttons {\n display: grid;\n grid-template-columns: repeat(5, minmax(0, 1fr));\n border: 1px solid var(--blr-reader-border);\n border-radius: 12px;\n overflow: hidden;\n background: var(--blr-reader-surface);\n}\n\n.blr-reading-stepper-btn {\n min-height: 28px;\n border: 0;\n border-right: 1px solid var(--blr-reader-border);\n background: transparent;\n color: var(--blr-reader-muted);\n font-size: 11px;\n font-weight: 600;\n cursor: pointer;\n transition: background 0.18s ease, color 0.18s ease;\n}\n\n.blr-reading-stepper-btn:last-child {\n border-right: 0;\n}\n\n.blr-reading-stepper-btn:hover {\n background: var(--blr-reader-accent-soft);\n color: var(--blr-reader-accent);\n}\n\n.blr-reading-stepper-btn.is-active {\n background: var(--blr-reader-accent-soft);\n color: var(--blr-reader-accent);\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-group {\n display: grid;\n gap: 6px;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-group + .blr-reading-settings-group {\n border-top: 1px solid var(--blr-reader-border);\n padding-top: 10px;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-info-group-chapters {\n display: grid;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-controls {\n display: flex;\n align-items: center;\n gap: 6px;\n flex-wrap: wrap;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-controls .blr-reading-select {\n min-width: 118px;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-control-field .blr-reading-select {\n min-width: 118px;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-secondary-btn {\n border: 1px solid var(--blr-reader-border);\n background: var(--blr-reader-surface);\n color: var(--blr-reader-text);\n min-height: 32px;\n padding: 0 12px;\n border-radius: 999px;\n cursor: pointer;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-toggle-inline {\n width: fit-content;\n background: var(--blr-reader-surface);\n color: var(--blr-reader-text);\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-panel .blr-reading-eyebrow {\n font-size: 10px;\n letter-spacing: 0.06em;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-panel .blr-reading-toggle,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-panel .blr-reading-select {\n min-height: 32px;\n padding: 0 11px;\n font-size: 12px;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-panel .blr-reading-toggle {\n gap: 6px;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-panel .blr-reading-info-list {\n gap: 4px;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-panel .blr-reading-info-item {\n gap: 8px;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-panel .blr-reading-info-label,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-panel .blr-reading-info-value,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-panel .blr-reading-info-copy,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-panel .blr-reading-empty,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-panel .blr-reading-text-btn {\n font-size: 12px;\n line-height: 1.42;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-panel .blr-reading-info-copy {\n white-space: pre-wrap;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-settings-panel .blr-reading-info-copy.is-collapsed {\n display: -webkit-box;\n overflow: hidden;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 5;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-rail {\n position: fixed;\n display: grid !important;\n left: var(--blr-reader-page-padding) !important;\n top: 112px !important;\n width: var(--blr-reader-rail-width) !important;\n max-height: calc(100vh - 136px) !important;\n padding: 12px;\n box-sizing: border-box;\n background: var(--blr-reader-surface);\n border: 1px solid var(--blr-reader-border);\n border-radius: 14px;\n box-shadow: var(--blr-reader-shadow);\n pointer-events: auto;\n z-index: 2147483646;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-chapter-visibility=\"hide\"] .blr-reading-rail,\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-chapter-visibility=\"hide\"] .blr-reading-rail,\n#blr-reading-view[data-chapter-visibility=\"hide\"] .blr-reading-rail,\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-chapter-visibility=\"auto\"][data-blr-reader-has-chapters=\"0\"] .blr-reading-rail,\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-chapter-visibility=\"auto\"][data-blr-reader-has-chapters=\"0\"] .blr-reading-rail,\n#blr-reading-view[data-chapter-visibility=\"auto\"][data-has-chapters=\"0\"] .blr-reading-rail {\n display: none !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] .left-container,\nbody[data-blr-reader-mode=\"1\"] .scroll-sticky,\nbody[data-blr-reader-mode=\"1\"] #playerWrap,\nbody[data-blr-reader-mode=\"1\"] .player-wrap,\nbody[data-blr-reader-mode=\"1\"] h1.video-title,\nbody[data-blr-reader-mode=\"1\"] .video-info-container,\nbody[data-blr-reader-mode=\"1\"] #viewbox_report,\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host {\n width: min(\n var(--blr-reader-main-width),\n var(--blr-reader-player-rendered-width, var(--blr-reader-main-width))\n ) !important;\n max-width: min(\n var(--blr-reader-main-width),\n var(--blr-reader-player-rendered-width, var(--blr-reader-main-width))\n ) !important;\n margin-left: auto !important;\n margin-right: auto !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host {\n min-height: 100vh;\n margin-top: -1px;\n padding: 0;\n border: 1px solid var(--blr-reader-border);\n border-top: 0;\n border-radius: 0 0 20px 20px;\n background: var(--blr-reader-surface-2);\n box-shadow: var(--blr-reader-shadow);\n box-sizing: border-box;\n overflow: auto;\n height: 100vh;\n scrollbar-width: thin;\n scrollbar-color: rgba(100, 116, 139, 0.1) transparent;\n position: relative;\n isolation: isolate;\n}\n\nbody[data-blr-reader-mode=\"1\"] #playerWrap,\nbody[data-blr-reader-mode=\"1\"] .player-wrap {\n border: 1px solid var(--blr-reader-border);\n border-bottom: 0;\n border-radius: 0;\n background: var(--blr-reader-surface);\n box-shadow: var(--blr-reader-shadow);\n box-sizing: border-box;\n overflow: visible !important;\n margin-top: -35px !important;\n height: var(--blr-reader-player-rendered-height, auto) !important;\n max-height: var(--blr-reader-player-rendered-height, none) !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] #playerWrap > *,\nbody[data-blr-reader-mode=\"1\"] .player-wrap > *,\nbody[data-blr-reader-mode=\"1\"] #playerWrap .bpx-player-container,\nbody[data-blr-reader-mode=\"1\"] #playerWrap .bpx-player-video-area,\nbody[data-blr-reader-mode=\"1\"] #playerWrap .bpx-player-primary-area,\nbody[data-blr-reader-mode=\"1\"] #playerWrap .bpx-player-inner,\nbody[data-blr-reader-mode=\"1\"] .player-wrap .bpx-player-container,\nbody[data-blr-reader-mode=\"1\"] .player-wrap .bpx-player-video-area,\nbody[data-blr-reader-mode=\"1\"] .player-wrap .bpx-player-primary-area,\nbody[data-blr-reader-mode=\"1\"] .player-wrap .bpx-player-inner {\n width: 100% !important;\n max-width: 100% !important;\n height: 100% !important;\n max-height: 100% !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-main {\n overflow: visible;\n display: block;\n width: 100%;\n background: transparent;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-transcript,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-item,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-item .blr-reading-text,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-time,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-chapter-title,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-empty,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-info-value,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-info-copy {\n color: var(--blr-reader-text) !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-item .blr-reading-text {\n font-size: var(--blr-reader-transcript-font-size) !important;\n font-weight: var(--blr-reader-transcript-font-weight) !important;\n line-height: var(--blr-reader-transcript-line-height) !important;\n letter-spacing: var(--blr-reader-letter-spacing) !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-tail-spacer {\n width: 100%;\n min-height: 320px;\n pointer-events: none;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-status {\n display: none !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] h1.video-title {\n margin-top: 25px !important;\n color: var(--blr-reader-text) !important;\n line-height: 1.5 !important;\n padding: 0 !important;\n display: block !important;\n height: auto !important;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host::-webkit-scrollbar {\n width: 6px;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host::-webkit-scrollbar-track {\n background: transparent;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host::-webkit-scrollbar-thumb {\n background: rgba(100, 116, 139, 0.1);\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host::-webkit-scrollbar-thumb:hover {\n background: rgba(100, 116, 139, 0.16);\n}\n\n@media (max-width: 1320px) {\n html[data-blr-reader-mode=\"1\"],\n body[data-blr-reader-mode=\"1\"] {\n --blr-reader-layout-gap: clamp(12px, 1.4vw, 16px);\n --blr-reader-rail-target-width: clamp(144px, 12vw, 168px);\n }\n}\n\n@media (max-width: 1320px) {\n html[data-blr-reader-mode=\"1\"][data-blr-reader-content-width=\"wide\"] .blr-reading-rail,\n body[data-blr-reader-mode=\"1\"][data-blr-reader-content-width=\"wide\"] .blr-reading-rail,\n #blr-reading-view[data-content-width=\"wide\"] .blr-reading-rail {\n display: none !important;\n }\n}\n\n@media (max-width: 1180px) {\n body[data-blr-reader-mode=\"1\"] .blr-reading-rail {\n display: none !important;\n }\n\n body[data-blr-reader-mode=\"1\"] .left-container,\n body[data-blr-reader-mode=\"1\"] .scroll-sticky,\n body[data-blr-reader-mode=\"1\"] #playerWrap,\n body[data-blr-reader-mode=\"1\"] .player-wrap,\n body[data-blr-reader-mode=\"1\"] h1.video-title,\n body[data-blr-reader-mode=\"1\"] .video-info-container,\n body[data-blr-reader-mode=\"1\"] #viewbox_report,\n body[data-blr-reader-mode=\"1\"] #blr-reading-inline-host {\n margin-left: auto !important;\n margin-right: auto !important;\n }\n}\n\nbody[data-blr-reader-mode=\"1\"] .bpx-player-mini-warp,\nbody[data-blr-reader-mode=\"1\"] .bpx-player-mini-close,\nbody[data-blr-reader-mode=\"1\"] .bpx-player-ending-panel,\nbody[data-blr-reader-mode=\"1\"] .bpx-player-ending-related,\nbody[data-blr-reader-mode=\"1\"] .ad-report,\nbody[data-blr-reader-mode=\"1\"] [class*=\"ad-report\"],\nbody[data-blr-reader-mode=\"1\"] [class*=\"mini-player\"],\nbody[data-blr-reader-mode=\"1\"] [class*=\"picture-in-picture\"] {\n display: none !important;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-timestamp-visible=\"0\"] .blr-reading-time,\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-timestamp-visible=\"0\"] .blr-reading-time,\n#blr-reading-view[data-timestamp-visible=\"0\"] .blr-reading-time {\n display: none !important;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-timestamp-visible=\"0\"] .blr-reading-item,\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-timestamp-visible=\"0\"] .blr-reading-item,\n#blr-reading-view[data-timestamp-visible=\"0\"] .blr-reading-item {\n grid-template-columns: minmax(0, 1fr);\n}\n\n.blr-reading-complete {\n padding: 12px 10px 20px;\n font-size: var(--blr-reader-transcript-font-size);\n font-weight: var(--blr-reader-transcript-font-weight);\n line-height: var(--blr-reader-transcript-line-height);\n letter-spacing: var(--blr-reader-letter-spacing);\n color: var(--blr-reader-text);\n text-align: left;\n}\n\n.blr-reading-complete-segment {\n display: inline;\n margin: 0;\n padding: 1px 0;\n border: 0;\n border-radius: 2px;\n background: transparent;\n color: inherit;\n font: inherit;\n letter-spacing: inherit;\n text-align: inherit;\n cursor: pointer;\n user-select: text;\n -webkit-user-select: text;\n}\n\n.blr-reading-complete-segment:hover {\n background: var(--blr-reader-accent-soft);\n}\n\n.blr-reading-complete-segment.is-active {\n text-decoration-line: underline;\n text-decoration-color: var(--blr-reader-accent);\n text-decoration-thickness: 2px;\n text-underline-offset: 4px;\n}\n\n/* Desktop reader: title/player with chapters below | transcript. */\n@media (min-width: 1181px) {\n html[data-blr-reader-mode=\"1\"],\n body[data-blr-reader-mode=\"1\"] {\n --blr-reader-three-column-gap: clamp(16px, 1.4vw, 24px);\n --blr-reader-three-column-half-gap: clamp(8px, 0.7vw, 12px);\n --blr-reader-rail-width: clamp(176px, 13vw, 220px);\n --blr-reader-transcript-width: clamp(320px, 24vw, 440px);\n --blr-reader-center-offset: clamp(-110px, -5.5vw, -72px);\n --blr-reader-main-width: max(\n 420px,\n calc(\n 100vw - (var(--blr-reader-page-padding) * 2) - var(--blr-reader-transcript-width) -\n var(--blr-reader-three-column-gap)\n )\n );\n overflow: hidden !important;\n }\n\n body[data-blr-reader-mode=\"1\"] .blr-reading-rail {\n left: var(--blr-reader-player-left, var(--blr-reader-page-padding)) !important;\n top: calc(var(--blr-reader-player-bottom, 72vh) + 12px) !important;\n bottom: auto !important;\n width: var(--blr-reader-player-width, var(--blr-reader-main-width)) !important;\n height: min(112px, calc(100vh - var(--blr-reader-player-bottom, 72vh) - 36px)) !important;\n min-height: 72px;\n max-height: none !important;\n grid-template-rows: auto minmax(0, 1fr);\n gap: 6px;\n padding: 10px 12px;\n border-radius: 10px;\n overflow: hidden;\n }\n\n body[data-blr-reader-mode=\"1\"] .blr-reading-rail .blr-reading-list {\n display: flex;\n align-items: stretch;\n gap: 8px;\n min-width: 0;\n padding: 0 0 4px;\n overflow-x: auto;\n overflow-y: hidden;\n scrollbar-width: thin;\n }\n\n body[data-blr-reader-mode=\"1\"] .blr-reading-rail .blr-reading-chapter {\n flex: 0 0 auto;\n width: auto;\n min-width: 120px;\n max-width: 190px;\n padding: 6px 10px;\n }\n\n body[data-blr-reader-mode=\"1\"] .left-container,\n body[data-blr-reader-mode=\"1\"] .scroll-sticky,\n body[data-blr-reader-mode=\"1\"] #playerWrap,\n body[data-blr-reader-mode=\"1\"] .player-wrap,\n body[data-blr-reader-mode=\"1\"] h1.video-title,\n body[data-blr-reader-mode=\"1\"] .video-info-container,\n body[data-blr-reader-mode=\"1\"] #viewbox_report {\n width: var(--blr-reader-main-width) !important;\n max-width: var(--blr-reader-main-width) !important;\n margin-left: auto !important;\n margin-right: auto !important;\n }\n\n body[data-blr-reader-mode=\"1\"] .left-container {\n transform: translateX(var(--blr-reader-center-offset)) !important;\n }\n\n body[data-blr-reader-mode=\"1\"] h1.video-title {\n margin-top: 28px !important;\n margin-bottom: 14px !important;\n }\n\n body[data-blr-reader-mode=\"1\"] #playerWrap,\n body[data-blr-reader-mode=\"1\"] .player-wrap {\n margin-top: 0 !important;\n border: 1px solid var(--blr-reader-border);\n border-radius: 4px;\n }\n\n body[data-blr-reader-mode=\"1\"] #blr-reading-inline-host {\n position: fixed !important;\n top: 98px !important;\n right: var(--blr-reader-page-padding) !important;\n bottom: 24px !important;\n left: auto !important;\n width: var(--blr-reader-transcript-width) !important;\n max-width: var(--blr-reader-transcript-width) !important;\n min-height: 0 !important;\n height: auto !important;\n margin: 0 !important;\n padding: 0 !important;\n border: 1px solid var(--blr-reader-border) !important;\n border-radius: 0 !important;\n background: var(--blr-reader-surface-2) !important;\n box-shadow: var(--blr-reader-shadow) !important;\n overflow-y: auto !important;\n scroll-padding-top: 46px;\n z-index: 2147483645;\n }\n\n html[data-blr-reader-mode=\"1\"][data-blr-reader-transcript-visible=\"0\"]\n #blr-reading-inline-host,\n body[data-blr-reader-mode=\"1\"][data-blr-reader-transcript-visible=\"0\"]\n #blr-reading-inline-host {\n display: none !important;\n }\n\n body[data-blr-reader-mode=\"1\"] #blr-reading-inline-host .blr-reading-transcript-heading {\n position: sticky;\n top: 0;\n display: flex;\n align-items: center;\n height: 38px;\n margin: 0;\n padding: 0 14px;\n box-sizing: border-box;\n color: var(--blr-reader-muted);\n background: var(--blr-reader-surface-2);\n border-bottom: 1px solid var(--blr-reader-border);\n font-size: 12px;\n font-weight: 700;\n letter-spacing: 0.08em;\n z-index: 3;\n pointer-events: auto;\n user-select: none;\n }\n\n body[data-blr-reader-mode=\"1\"] #blr-reading-inline-host .blr-reading-main,\n body[data-blr-reader-mode=\"1\"] #blr-reading-inline-host .blr-reading-transcript {\n width: 100% !important;\n max-width: none !important;\n height: auto !important;\n margin: 0 !important;\n box-sizing: border-box;\n }\n\n body[data-blr-reader-mode=\"1\"] #blr-reading-inline-host .blr-reading-transcript {\n padding: 8px 10px 24px;\n overflow: visible;\n }\n\n body[data-blr-reader-mode=\"1\"] #blr-reading-inline-host .blr-reading-item {\n grid-template-columns: 52px minmax(0, 1fr);\n gap: 8px;\n padding: 7px 8px;\n border-radius: 10px;\n }\n\n body[data-blr-reader-mode=\"1\"] .blr-reading-tail-spacer {\n min-height: 45vh;\n }\n\n body[data-blr-reader-mode=\"1\"] .blr-reading-resize-handle {\n position: fixed;\n top: 88px;\n bottom: 24px;\n width: 14px;\n z-index: 2147483646;\n cursor: col-resize;\n pointer-events: auto;\n touch-action: none;\n }\n\n body[data-blr-reader-mode=\"1\"] .blr-reading-resize-handle::before {\n content: \"\";\n position: absolute;\n top: 0;\n bottom: 0;\n left: 6px;\n width: 2px;\n border-radius: 999px;\n background: transparent;\n transition: background 0.16s ease, box-shadow 0.16s ease;\n }\n\n body[data-blr-reader-mode=\"1\"] .blr-reading-resize-handle:hover::before,\n body[data-blr-reader-resizing] .blr-reading-resize-handle::before {\n background: var(--blr-reader-accent);\n box-shadow: 0 0 0 3px var(--blr-reader-accent-soft);\n }\n\n body[data-blr-reader-mode=\"1\"] .blr-reading-resize-handle-left {\n display: block !important;\n left: var(--blr-reader-player-left, var(--blr-reader-page-padding)) !important;\n right: auto !important;\n top: calc(var(--blr-reader-player-bottom, 72vh) + 1px) !important;\n bottom: auto !important;\n width: var(--blr-reader-player-width, var(--blr-reader-main-width)) !important;\n height: 10px;\n cursor: row-resize;\n }\n\n body[data-blr-reader-mode=\"1\"] .blr-reading-resize-handle-left::before {\n top: 4px;\n right: 0;\n bottom: auto;\n left: 0;\n width: auto;\n height: 2px;\n }\n\n body[data-blr-reader-mode=\"1\"] .blr-reading-resize-handle-right {\n right: calc(\n var(--blr-reader-page-padding) + var(--blr-reader-transcript-width) +\n var(--blr-reader-three-column-half-gap) - 7px\n );\n }\n\n body[data-blr-reader-resizing=\"transcript\"],\n body[data-blr-reader-resizing=\"transcript\"] * {\n cursor: col-resize !important;\n user-select: none !important;\n }\n\n body[data-blr-reader-resizing=\"chapter\"],\n body[data-blr-reader-resizing=\"chapter\"] * {\n cursor: row-resize !important;\n user-select: none !important;\n }\n\n body[data-blr-reader-transcript-visible=\"0\"] .blr-reading-resize-handle-right {\n display: none !important;\n }\n}\n\n@media (max-width: 1180px) {\n .blr-reading-resize-handle {\n display: none !important;\n }\n}\n\n/* Timestamp-free transcript rows must not retain the desktop time column. */\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-timestamp-visible=\"0\"]\n .blr-reading-item,\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-timestamp-visible=\"0\"]\n .blr-reading-item,\n#blr-reading-view[data-timestamp-visible=\"0\"] .blr-reading-item {\n grid-template-columns: minmax(0, 1fr) !important;\n}\n\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-timestamp-visible=\"0\"]\n .blr-reading-item\n .blr-reading-text,\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-timestamp-visible=\"0\"]\n .blr-reading-item\n .blr-reading-text,\n#blr-reading-view[data-timestamp-visible=\"0\"] .blr-reading-item .blr-reading-text {\n grid-column: 1 / -1;\n min-width: 0;\n}\n");
const chrome = {
runtime: {
lastError: null,
onMessage: {
addListener(listener) {
runtimeListeners.push(listener);
},
removeListener(listener) {
const index = runtimeListeners.indexOf(listener);
if (index >= 0) runtimeListeners.splice(index, 1);
}
},
sendMessage(message, callback) {
handleBackgroundMessage(message)
.then((response) => callback?.(response))
.catch((error) => callback?.({ ok: false, error: String(error?.message || error) }));
}
},
storage: {
onChanged: {
addListener(listener) {
storageListeners.push(listener);
},
removeListener(listener) {
const index = storageListeners.indexOf(listener);
if (index >= 0) storageListeners.splice(index, 1);
}
},
local: {
async get(keys) {
if (typeof keys === "string") {
return { [keys]: await Promise.resolve(GM_getValue(keys, undefined)) };
}
const result = {};
for (const key of Array.isArray(keys) ? keys : Object.keys(keys || {})) {
result[key] = await Promise.resolve(GM_getValue(key, keys?.[key]));
}
return result;
},
async set(values) {
for (const [key, value] of Object.entries(values || {})) {
await Promise.resolve(GM_setValue(key, value));
}
},
async remove(keys) {
for (const key of Array.isArray(keys) ? keys : [keys]) {
await Promise.resolve(GM_deleteValue(key));
}
}
}
}
};
async function handleBackgroundMessage(message) {
if (message?.type === "get-settings") {
const settings = await Promise.resolve(GM_getValue(USERSCRIPT_SETTINGS_KEY, {}));
return { ok: true, settings: settings && typeof settings === "object" ? settings : {} };
}
if (message?.type === "save-settings") {
const previous = await Promise.resolve(GM_getValue(USERSCRIPT_SETTINGS_KEY, {}));
const settings = message.settings && typeof message.settings === "object" ? message.settings : {};
await Promise.resolve(GM_setValue(USERSCRIPT_SETTINGS_KEY, settings));
const changes = {};
for (const key of new Set([...Object.keys(previous || {}), ...Object.keys(settings)])) {
if (previous?.[key] !== settings[key]) {
changes[key] = { oldValue: previous?.[key], newValue: settings[key] };
}
}
storageListeners.forEach((listener) => listener(changes, "sync"));
return { ok: true };
}
if (message?.type === "fetch-json") {
return { ok: true, data: await requestJson(String(message.url || "")) };
}
return { ok: false, error: "此功能在独立阅读脚本中不可用" };
}
async function requestJson(url) {
if (!url) throw new Error("缺少请求地址");
const normalizedUrl = url.startsWith("//") ? `https:${url}` : url;
let directError = null;
try {
const pageFetch =
typeof unsafeWindow !== "undefined" && typeof unsafeWindow.fetch === "function"
? unsafeWindow.fetch.bind(unsafeWindow)
: fetch;
const response = await pageFetch(normalizedUrl, {
method: "GET",
credentials: "include",
cache: "no-store",
headers: { Accept: "application/json, text/plain, */*" }
});
if (response.ok) return await response.json();
directError = new Error(`页面请求 HTTP ${response.status}`);
} catch (error) {
directError = error;
}
return requestJsonWithUserscriptApi(normalizedUrl, directError);
}
function requestJsonWithUserscriptApi(url, directError = null) {
return new Promise((resolve, reject) => {
let settled = false;
const finish = (handler, value) => {
if (settled) return;
settled = true;
handler(value);
};
const onload = (response) => {
if (response.status < 200 || response.status >= 300) {
finish(reject, new Error(`HTTP ${response.status}`));
return;
}
try {
const payload =
response.response && typeof response.response === "object"
? response.response
: JSON.parse(response.responseText || response.response || "");
finish(resolve, payload);
} catch {
finish(reject, new Error("返回内容不是有效 JSON"));
}
};
const onerror = (error) =>
finish(
reject,
new Error(
error?.error ||
`网络请求失败${directError?.message ? `(${directError.message})` : ""}`
)
);
const requestOptions = {
method: "GET",
url,
headers: {
Accept: "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
Referer: "https://www.bilibili.com/"
},
responseType: "text",
anonymous: false,
withCredentials: true,
timeout: 20000,
onload,
ontimeout() { finish(reject, new Error("请求超时")); },
onerror
};
const requestApi =
typeof GM_xmlhttpRequest === "function"
? GM_xmlhttpRequest
: typeof GM !== "undefined" && typeof GM.xmlHttpRequest === "function"
? GM.xmlHttpRequest.bind(GM)
: null;
if (!requestApi) {
finish(reject, new Error("脚本管理器未提供跨域请求权限"));
return;
}
try {
const request = requestApi(requestOptions);
if (request && typeof request.then === "function") {
request.then(onload).catch(onerror);
}
} catch (error) {
onerror(error);
}
});
}
function dispatchRuntimeMessage(message) {
return new Promise((resolve) => {
let settled = false;
const respond = (response) => {
if (settled) return;
settled = true;
resolve(response);
};
for (const listener of runtimeListeners) {
const keepChannelOpen = listener(message, null, respond);
if (settled) return;
if (keepChannelOpen === true) {
window.setTimeout(() => respond({ ok: false, error: "操作超时" }), 5000);
return;
}
}
respond({ ok: false, error: "阅读脚本尚未就绪" });
});
}
GM_registerMenuCommand("进入阅读模式", () => {
const readerUrl = new URL(location.href);
readerUrl.searchParams.set("bilibli_reader", "1");
dispatchRuntimeMessage({
type: "popup-trigger-reading-view",
readerUrl: readerUrl.toString()
});
});
GM_registerMenuCommand("退出阅读模式", () => {
const closeButton = document.getElementById("blr-reading-close-btn");
if (closeButton) closeButton.click();
});
(() => {
if (globalThis.__BILIBLI_READER_CONTENT_SCRIPT_BOOTSTRAPPED__) {
return;
}
globalThis.__BILIBLI_READER_CONTENT_SCRIPT_BOOTSTRAPPED__ = true;
const DEFAULT_SETTINGS = {
noteFolder: "Clippings/Bilibili",
obsidianApiBaseUrl: "http://127.0.0.1:27123",
obsidianApiKey: "",
tags: "clippings,bilibili",
downloadFormat: "srt",
includeDateInFilename: true,
includeHotCommentsInNote: false,
enablePlayerAiQuickAction: false,
playerAiQuickPrompt: "整理这期视频的内容,输出结构化总结:主题、核心观点、关键细节、结论与可执行启发。",
includeTimestampInBody: true,
enableDebugLogs: false,
readerTheme: "light",
readerFontScale: "xl",
readerFontWeight: "bold",
readerLetterSpacing: "loose",
readerLineHeight: "loose",
readerContentWidth: "medium",
readerChapterWidthPx: 220,
readerTranscriptWidthPx: 440,
readerVideoHeightPx: 0,
readerChapterVisibility: "show",
readerTranscriptVisible: true,
readerTimestampVisible: true,
readerTranscriptMode: "complete",
readerDefaultsVersion: 3,
frontmatterFields: [
"title",
"url",
"bvid",
"cid",
"author",
"upload_date",
"subtitle_lang",
"created",
"tags"
],
fixedFrontmatterProperties: [],
notePlaceholderSections: []
};
const PLAYER_AI_ICON_VARIANT = "badge";
const READER_VERSION = "0.0.1";
const CACHE_KEY_PREFIX = "bilibli_reader_subtitle_cache_";
globalThis.__BILIBLI_READER_CONTENT_SCRIPT_LOADED__ = READER_VERSION;
const state = {
currentUrl: location.href,
fetchRunId: 0,
bvid: "",
aid: "",
cid: "",
cidSource: "",
pageIndex: 1,
pageCount: 0,
pageTitle: "",
videoDuration: 0,
description: "",
title: "",
author: "",
uploadDate: "",
subtitles: [],
selectedSubtitleId: "",
selectedSubtitleUrl: "",
selectedSubtitleLang: "",
subtitleBody: [],
subtitleFetchState: "idle",
chapters: [],
hotComments: [],
markdown: "",
srt: "",
txt: "",
readingViewOpen: false,
readingNativePageMode: false,
readingRootOriginalParent: null,
readingAutoScroll: true,
readingTheme: "light",
readingFontScale: "m",
readingFontWeight: "normal",
readingLetterSpacing: "normal",
readingLineHeight: "tight",
readingContentWidth: "medium",
readingChapterWidthPx: 220,
readingTranscriptWidthPx: 440,
readingVideoHeightPx: 0,
readingChapterVisible: true,
readingTranscriptVisible: true,
readingTimestampVisible: true,
readingTranscriptMode: "fragmented",
readingSettingsExpanded: false,
readingDescriptionExpanded: false,
readingActiveSubtitleIndex: -1,
readingActiveChapterIndex: -1,
readingNextScrollBehavior: "smooth",
readingSyncTimer: 0,
currentClipSignature: "",
readingVideoEl: null,
readingPlayerHost: null,
readingMainOriginalParent: null,
readingMainOriginalNextSibling: null,
readingPlayerAdjustedNodes: [],
readingPlayerObserver: null,
readingPlayerMountTimer: 0,
readingPlayerRetryTimer: 0,
readingMiniDismissTimer: 0,
readingControlsHideTimer: 0,
readingControlsRecoveryTimer: 0,
readingControlsRecoveryInFlight: false,
readingControlsLastRecoverAt: 0,
readingControlsHoverHost: null,
readingHeaderHoverHost: null,
readingHeaderHideTimer: 0,
readingVideoEventsBound: false,
readingLayoutBound: false,
uiEventsBound: false,
runtimeEventsBound: false,
settingsWatcherBound: false,
normalPageStateGuardBound: false,
urlWatcherStarted: false,
playerAiQuickActionObserver: null,
playerAiQuickActionLayoutBound: false,
playerAiQuickActionSyncTimer: 0,
playerAiQuickActionRevealTimer: 0,
playerAiQuickActionHideTimer: 0,
playerAiQuickActionCursorHideTimer: 0,
playerAiQuickActionSubmitting: false,
playerAiQuickActionSuppressedUntil: 0,
pageReaderEntryObserver: null,
pageReaderEntrySyncTimer: 0,
normalPageStateObserver: null,
readingDocumentClickBound: false,
readingManualScrollPauseUntil: 0,
readingProgrammaticScrollUntil: 0,
readingViewReady: false,
statusText: "准备就绪,点击“刷新抓取”开始。",
messageText: "",
settings: { ...DEFAULT_SETTINGS }
};
function formatLocalDate(value = Date.now()) {
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function isReaderMode(url = location.href) {
try {
return new URL(url).searchParams.get("bilibli_reader") === "1";
} catch {
return false;
}
}
function stripReaderModeUrl(url = location.href) {
try {
const parsed = new URL(url);
parsed.searchParams.delete("bilibli_reader");
return parsed.toString();
} catch {
return url;
}
}
function replaceReaderModeUrl(nextUrl) {
const targetUrl = String(nextUrl || "").trim();
if (!targetUrl || targetUrl === location.href) {
return;
}
try {
history.replaceState(history.state, "", targetUrl);
state.currentUrl = location.href;
state.currentClipSignature = computeCurrentClipSignature(location.href);
} catch (error) {
logWarn("[BOC] failed to replace reader mode url", error);
}
}
function isWatchlaterPage(url = location.href) {
try {
return new URL(url).pathname.replace(/\/+$/, "") === "/list/watchlater";
} catch {
return false;
}
}
function getReaderContentMaxPx() {
if (state.readingContentWidth === "compact") {
return 720;
}
if (state.readingContentWidth === "narrow") {
return 820;
}
if (state.readingContentWidth === "wide") {
return 1120;
}
if (state.readingContentWidth === "full") {
return 1280;
}
return 980;
}
function getReaderPagePaddingPx() {
return Math.min(32, Math.max(16, window.innerWidth * 0.028));
}
function getReaderMainWidthLimit() {
const pagePadding = getReaderPagePaddingPx();
if (window.innerWidth > 1180) {
const { transcriptWidth } = getEffectiveReaderColumnWidths();
const gap = Math.min(24, Math.max(16, window.innerWidth * 0.014));
const availableWidth = window.innerWidth - pagePadding * 2 - transcriptWidth - gap;
const heightLimitedWidth = Math.max(420, (window.innerHeight - 190) * (16 / 9));
return Math.max(420, Math.min(availableWidth, heightLimitedWidth));
}
return Math.max(320, Math.min(getReaderContentMaxPx(), window.innerWidth - pagePadding * 2));
}
function normalizeReaderColumnWidth(value, fallback, min, max) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.round(Math.min(max, Math.max(min, parsed))) : fallback;
}
function normalizeReaderVideoHeight(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return 0;
return Math.round(Math.min(900, Math.max(240, parsed)));
}
function getEffectiveReaderColumnWidths() {
const pagePadding = getReaderPagePaddingPx();
const gap = Math.min(24, Math.max(16, window.innerWidth * 0.014));
const minChapterWidth = 140;
const minTranscriptWidth = 280;
const minVideoWidth = 420;
const chapterWidth = normalizeReaderColumnWidth(state.readingChapterWidthPx, 220, minChapterWidth, 360);
let transcriptWidth = normalizeReaderColumnWidth(state.readingTranscriptWidthPx, 440, minTranscriptWidth, 720);
const availableForTranscript = Math.max(
minTranscriptWidth,
window.innerWidth - pagePadding * 2 - gap - minVideoWidth
);
transcriptWidth = Math.min(transcriptWidth, availableForTranscript);
return {
chapterWidth: Math.round(chapterWidth),
transcriptWidth: Math.round(transcriptWidth),
gap
};
}
function applyReaderColumnLayout() {
const readingView = byId(ids.readingView);
if (!readingView) return;
const { chapterWidth, transcriptWidth, gap } = getEffectiveReaderColumnWidths();
const mainWidth = getReaderMainWidthLimit();
const centerOffset = Math.round(-(transcriptWidth + gap) / 2);
[document.documentElement, document.body, readingView].forEach((node) => {
node.style.setProperty("--blr-reader-rail-width", `${chapterWidth}px`);
node.style.setProperty("--blr-reader-transcript-width", `${transcriptWidth}px`);
node.style.setProperty("--blr-reader-center-offset", `${centerOffset}px`);
node.style.setProperty("--blr-reader-main-width", `${Math.round(mainWidth)}px`);
if (state.readingVideoHeightPx > 0) {
node.style.setProperty(
"--blr-reader-player-rendered-height",
`${Math.round(state.readingVideoHeightPx)}px`
);
}
});
document
.getElementById(ids.readingChapterResizeHandle)
?.setAttribute("aria-valuenow", String(state.readingVideoHeightPx || 0));
document.getElementById(ids.readingTranscriptResizeHandle)?.setAttribute("aria-valuenow", String(transcriptWidth));
}
function updateReaderChapterRailPosition(rect = null) {
const readingView = document.getElementById(ids.readingView);
if (!readingView || window.innerWidth <= 1180) return;
const playerNode = getReaderPlayerWrapNode() || state.readingPlayerHost;
const playerRect = rect || playerNode?.getBoundingClientRect?.();
if (!playerRect || !(playerRect.width > 0) || !(playerRect.height > 0)) return;
readingView.style.setProperty("--blr-reader-player-left", `${Math.round(playerRect.left)}px`);
readingView.style.setProperty("--blr-reader-player-bottom", `${Math.round(playerRect.bottom)}px`);
readingView.style.setProperty("--blr-reader-player-width", `${Math.round(playerRect.width)}px`);
document
.getElementById(ids.readingChapterResizeHandle)
?.setAttribute("aria-valuenow", String(Math.round(playerRect.height)));
}
function clearNativeReaderFloatingStyles(playerHost = state.readingPlayerHost) {
if (!state.readingNativePageMode || !playerHost) {
return;
}
const targets = [];
let current = playerHost;
let depth = 0;
while (current && current !== document.body && depth < 8) {
if (
current.matches?.(
".bpx-player-container, .bpx-docker, .bpx-player-video-area, .bpx-player-primary-area, #bilibili-player, #playerWrap, .player-wrap"
)
) {
targets.push(current);
}
if (current.id === "playerWrap") {
break;
}
current = current.parentElement;
depth += 1;
}
targets.forEach((node) => {
node.style.removeProperty("position");
node.style.removeProperty("inset");
node.style.removeProperty("left");
node.style.removeProperty("top");
node.style.removeProperty("right");
node.style.removeProperty("bottom");
node.style.removeProperty("transform");
node.style.removeProperty("width");
node.style.removeProperty("height");
node.style.removeProperty("max-width");
node.style.removeProperty("max-height");
node.style.removeProperty("margin");
node.style.removeProperty("z-index");
});
}
function getReaderPlayerWrapNode(playerHost = state.readingPlayerHost) {
return (
playerHost?.closest?.("#playerWrap") ||
playerHost?.closest?.(".player-wrap") ||
document.getElementById("playerWrap") ||
document.querySelector(".player-wrap")
);
}
function hasNativeReaderPlayerLayoutIssue(playerHost = state.readingPlayerHost) {
if (!state.readingNativePageMode || !playerHost) {
return false;
}
const playerStyle = window.getComputedStyle(playerHost);
if (playerStyle.position === "fixed" || playerStyle.position === "sticky") {
return true;
}
const playerRect = playerHost.getBoundingClientRect();
const wrapNode = getReaderPlayerWrapNode(playerHost);
if (!wrapNode) {
return false;
}
const wrapRect = wrapNode.getBoundingClientRect();
return wrapRect.height <= 8 && playerRect.height > 120;
}
function normalizeReaderTheme(value) {
return value === "dark" || value === "paper" ? value : "light";
}
function normalizeReaderFontScale(value) {
return ["xs", "s", "m", "l", "xl"].includes(value) ? value : "xl";
}
function normalizeReaderFontWeight(value) {
return ["light", "regular", "normal", "semibold", "bold"].includes(value) ? value : "bold";
}
function normalizeReaderLetterSpacing(value) {
return ["tighter", "tight", "normal", "relaxed", "loose"].includes(value) ? value : "loose";
}
function normalizeReaderLineHeight(value) {
return ["compact", "tight", "normal", "relaxed", "loose"].includes(value) ? value : "loose";
}
function normalizeReaderContentWidth(value) {
return ["compact", "narrow", "medium", "wide", "full"].includes(value) ? value : "medium";
}
function normalizeReaderChapterVisibility(value) {
return value === "hide" || value === "auto" ? value : "show";
}
function normalizeReaderTranscriptVisible(value) {
return value !== false;
}
function normalizeReaderTimestampVisible(value) {
return value !== false;
}
function normalizeReaderTranscriptMode(value) {
return value === "fragmented" ? "fragmented" : "complete";
}
function shouldDebugLog() {
return Boolean(state.settings?.enableDebugLogs);
}
function logInfo(...args) {
if (shouldDebugLog()) {
console.info(...args);
}
}
function logWarn(...args) {
if (shouldDebugLog()) {
console.warn(...args);
}
}
function installReaderDebugHelpers() {
const snapshotReader = (label = "manual") => createReaderDebugSnapshot(label);
globalThis.__BILIBLI_READER_DEBUG_SNAPSHOT__ = snapshotReader;
globalThis.__BILIBLI_READER_DEBUG__ = {
...(globalThis.__BILIBLI_READER_DEBUG__ || {}),
snapshotReader
};
}
const ids = {
root: "blr-root",
panel: "blr-panel",
status: "blr-status",
meta: "blr-meta",
subtitleSelect: "blr-subtitle-select",
preview: "blr-preview",
message: "blr-message",
copyBtn: "blr-copy-btn",
downloadBtn: "blr-download-btn",
sendBtn: "blr-send-btn",
refreshBtn: "blr-refresh-btn",
closeBtn: "blr-close-btn",
settingsBtn: "blr-settings-btn",
readingView: "blr-reading-view",
readingPlayerSlot: "blr-reading-player-slot",
readingStatus: "blr-reading-status",
readingCloseBtn: "blr-reading-close-btn",
readingRefreshBtn: "blr-reading-refresh-btn",
readingAutoScroll: "blr-reading-autoscroll",
readingTranscriptVisible: "blr-reading-transcript-visible",
readingTimestampVisible: "blr-reading-timestamp-visible",
readingThemeSelect: "blr-reading-theme-select",
readingSettingsBtn: "blr-reading-settings-btn",
readingSettingsPanel: "blr-reading-settings-panel",
readingFontScaleSelect: "blr-reading-font-scale-select",
readingFontWeightSelect: "blr-reading-font-weight-select",
readingLetterSpacingSelect: "blr-reading-letter-spacing-select",
readingLineHeightSelect: "blr-reading-line-height-select",
readingChapterResizeHandle: "blr-reading-chapter-resize-handle",
readingTranscriptResizeHandle: "blr-reading-transcript-resize-handle",
readingChapterVisibilitySelect: "blr-reading-chapter-visibility-select",
readingChapterVisible: "blr-reading-chapter-visible",
readingSubtitleSelect: "blr-reading-subtitle-select",
readingTranscriptModeSelect: "blr-reading-transcript-mode-select",
readingInfoSummary: "blr-reading-info-summary",
readingInfoDescription: "blr-reading-info-description",
readingDescriptionBtn: "blr-reading-description-btn",
readingMeta: "blr-reading-meta",
readingChapterList: "blr-reading-chapters",
readingTranscriptList: "blr-reading-transcript",
readingTranscriptTailSpacer: "blr-reading-tail-spacer"
};
init();
function init() {
logInfo(`[Bilibili Reader] content script loaded, version=${READER_VERSION}`);
ensureUiReady({ forceRecreate: true });
installReaderDebugHelpers();
const shouldEnterReaderMode = isReaderMode();
if (shouldEnterReaderMode) {
document.documentElement.setAttribute("data-blr-reader-mode", "1");
document.body.setAttribute("data-blr-reader-mode", "1");
} else {
clearReaderModePageState();
}
bindRuntimeEvents();
bindSettingsWatcher();
bindNormalPageStateGuard();
bindPlayerAiQuickActionLayoutEvents();
startPageReaderEntryObserver();
startUrlWatcher();
getSettings().then((settings) => {
state.settings = settings;
hydrateReaderStateFromSettings(settings);
applyReadingViewPresentation();
startPlayerAiQuickActionObserver();
schedulePlayerAiQuickActionSync();
if (shouldEnterReaderMode) {
enterReaderMode().catch((error) => {
renderReadingStatus(`阅读视图启动失败:${getErrorMessage(error)}`);
});
}
});
}
function ensureUiReady({ forceRecreate = false } = {}) {
const existingRoot = document.getElementById(ids.root);
if (existingRoot && forceRecreate) {
existingRoot.remove();
state.uiEventsBound = false;
}
let root = document.getElementById(ids.root);
if (!root) {
root = document.createElement("div");
root.id = ids.root;
root.innerHTML = buildUiHtml();
document.body.appendChild(root);
state.uiEventsBound = false;
}
if (!state.uiEventsBound) {
bindUiEvents();
state.uiEventsBound = true;
}
}
function clearReaderModePageState() {
document.documentElement.removeAttribute("data-blr-reader-mode");
document.documentElement.removeAttribute("data-blr-reader-line-height");
document.documentElement.removeAttribute("data-blr-reader-theme");
document.documentElement.removeAttribute("data-blr-reader-font-scale");
document.documentElement.removeAttribute("data-blr-reader-font-weight");
document.documentElement.removeAttribute("data-blr-reader-letter-spacing");
document.documentElement.removeAttribute("data-blr-reader-content-width");
document.documentElement.removeAttribute("data-blr-reader-chapter-visibility");
document.documentElement.removeAttribute("data-blr-reader-has-chapters");
document.documentElement.removeAttribute("data-blr-reader-transcript-visible");
document.documentElement.removeAttribute("data-blr-reader-timestamp-visible");
document.documentElement.removeAttribute("data-blr-reader-transcript-mode");
document.body.removeAttribute("data-blr-reader-mode");
document.body.removeAttribute("data-blr-reader-line-height");
document.body.removeAttribute("data-blr-reader-font-weight");
document.body.removeAttribute("data-blr-reader-transcript-visible");
document.body.removeAttribute("data-blr-reader-timestamp-visible");
document.body.removeAttribute("data-blr-reader-transcript-mode");
document.body.removeAttribute("data-blr-reading-active");
}
function startPageReaderEntryObserver() {
ensurePageReaderEntryButton();
if (state.pageReaderEntryObserver) {
return;
}
state.pageReaderEntryObserver = new MutationObserver(() => {
schedulePageReaderEntrySync();
});
state.pageReaderEntryObserver.observe(document.body, {
childList: true,
subtree: true
});
}
function schedulePageReaderEntrySync(delayMs = 120) {
if (state.pageReaderEntrySyncTimer) {
return;
}
state.pageReaderEntrySyncTimer = window.setTimeout(() => {
state.pageReaderEntrySyncTimer = 0;
ensurePageReaderEntryButton();
}, delayMs);
}
function ensurePageReaderEntryButton() {
if (!extractBvid(location.href) && !isWatchlaterPage()) {
return;
}
const host = document.querySelector(
"#viewbox_report, .video-info-container, .video-info-title"
);
if (!host) {
return;
}
let button = document.getElementById("blr-page-reader-entry");
if (button && button.parentElement === host) {
return;
}
if (button) {
button.parentElement?.classList.remove("blr-page-reader-entry-host");
button.remove();
}
host.classList.add("blr-page-reader-entry-host");
button = document.createElement("button");
button.id = "blr-page-reader-entry";
button.type = "button";
button.title = "进入 Bilibili Reader 阅读模式";
button.setAttribute("aria-label", "进入阅读模式");
button.innerHTML = `
阅读
`;
button.addEventListener("click", onPageReaderEntryClick);
host.appendChild(button);
}
function onPageReaderEntryClick(event) {
event.preventDefault();
event.stopPropagation();
if (state.readingViewOpen) {
return;
}
const button = event.currentTarget;
button.disabled = true;
button.classList.add("is-loading");
const readerUrl = new URL(location.href);
readerUrl.searchParams.set("bilibli_reader", "1");
replaceReaderModeUrl(readerUrl.toString());
document.documentElement.setAttribute("data-blr-reader-mode", "1");
document.body.setAttribute("data-blr-reader-mode", "1");
state.playerAiQuickActionSuppressedUntil = Date.now() + 2500;
removePlayerAiQuickActionButton();
ensureUiReady();
enterReaderMode().catch((error) => {
button.disabled = false;
button.classList.remove("is-loading");
logWarn("[Bilibili Reader] page entry failed", error);
renderReadingStatus(`阅读视图启动失败:${getErrorMessage(error)}`);
});
}
function shouldForceNormalPageState(url = location.href) {
return !isReaderMode(url) && !state.readingViewOpen;
}
function enforceNormalPageStateIfNeeded(url = location.href) {
if (!shouldForceNormalPageState(url)) {
return;
}
clearReaderModePageState();
}
function bindNormalPageStateGuard() {
if (state.normalPageStateGuardBound) {
return;
}
state.normalPageStateGuardBound = true;
const observer = new MutationObserver(() => {
enforceNormalPageStateIfNeeded();
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: [
"data-blr-reader-mode",
"data-blr-reader-line-height",
"data-blr-reader-theme",
"data-blr-reader-font-scale",
"data-blr-reader-letter-spacing",
"data-blr-reader-content-width",
"data-blr-reader-chapter-visibility",
"data-blr-reader-has-chapters",
"data-blr-reader-transcript-visible"
]
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ["data-blr-reader-mode", "data-blr-reader-line-height", "data-blr-reading-active"]
});
state.normalPageStateObserver = observer;
enforceNormalPageStateIfNeeded();
}
function bindRuntimeEvents() {
if (state.runtimeEventsBound) {
return;
}
state.runtimeEventsBound = true;
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (!message || typeof message !== "object") {
return false;
}
if (message.type === "popup-get-state") {
sendResponse({ ok: true, payload: getPopupPayload() });
return false;
}
if (message.type === "popup-refresh") {
refreshClip()
.then(() => sendResponse({ ok: true, payload: getPopupPayload() }))
.catch((error) =>
sendResponse({ ok: false, error: getErrorMessage(error), payload: getPopupPayload() })
);
return true;
}
if (message.type === "popup-select-subtitle") {
const url = String(message.url || "").trim();
const lang = String(message.lang || "unknown");
const subtitleId = String(message.subtitleId || "");
if (!url) {
sendResponse({ ok: false, error: "Missing subtitle URL", payload: getPopupPayload() });
return false;
}
loadSubtitle(url, lang, state.fetchRunId, subtitleId)
.then(() => {
setStatus("字幕切换完成。");
renderSubtitleSelect();
sendResponse({ ok: true, payload: getPopupPayload() });
})
.catch((error) =>
sendResponse({ ok: false, error: getErrorMessage(error), payload: getPopupPayload() })
);
return true;
}
if (message.type === "popup-send-obsidian") {
sendToObsidian()
.then(() => sendResponse({ ok: true, payload: getPopupPayload() }))
.catch((error) =>
sendResponse({ ok: false, error: getErrorMessage(error), payload: getPopupPayload() })
);
return true;
}
if (message.type === "popup-trigger-reading-view") {
state.playerAiQuickActionSuppressedUntil = Date.now() + 2500;
removePlayerAiQuickActionButton();
ensureUiReady();
const readerUrl = String(message.readerUrl || "").trim();
if (readerUrl) {
replaceReaderModeUrl(readerUrl);
document.documentElement.setAttribute("data-blr-reader-mode", "1");
document.body.setAttribute("data-blr-reader-mode", "1");
}
if (!state.readingViewOpen) {
enterReaderMode().catch((error) => {
logWarn("[BOC] reading mode trigger failed", error);
});
}
sendResponse({ ok: true });
return true;
}
if (message.type === "sidepanel-get-context") {
const settings = state.settings || DEFAULT_SETTINGS;
const body = state.subtitleBody || [];
let subtitleMarkdown = "";
try {
subtitleMarkdown = body.length
? buildMarkdown(state, body, { ...settings, includeHotCommentsInNote: false })
: "";
} catch (e) {
subtitleMarkdown = "";
logWarn("[BOC] sidepanel-get-context: buildMarkdown failed", e);
}
sendResponse({
ok: true,
payload: {
url: location.href,
title: state.title || "",
author: state.author || "",
uploadDate: state.uploadDate || "",
bvid: state.bvid || "",
cid: state.cid || "",
aid: state.aid || "",
pageIndex: Number(state.pageIndex) > 0 ? Number(state.pageIndex) : 1,
pageCount: Number(state.pageCount) > 0 ? Number(state.pageCount) : 0,
pageTitle: state.pageTitle || "",
subtitleBody: body,
subtitleMarkdown,
subtitleLang: state.selectedSubtitleLang || "",
selectedSubtitleId: state.selectedSubtitleId || "",
selectedSubtitleUrl: state.selectedSubtitleUrl || "",
subtitleOptions: state.subtitles || [],
hotComments: []
}
});
return false;
}
if (message.type === "sidepanel-get-hot-comments") {
const count = 20; // 固定取前 20 条热门评论
if (!count) {
sendResponse({ ok: true, comments: [] });
return false;
}
if (!getCurrentAid()) {
state.hotComments = [];
sendResponse({ ok: true, comments: [], note: "无法获取视频 aid" });
return false;
}
fetchHotComments(count)
.then((hotComments) => {
state.hotComments = hotComments;
sendResponse({ ok: true, comments: hotComments });
})
.catch((error) => {
state.hotComments = [];
sendResponse({ ok: true, comments: [], note: String(error?.message || error) });
});
return true;
}
if (message.type === "sidepanel-seek-video-time") {
const seconds = Number(message.seconds);
const video = getRuntimeVideoElement();
if (!video) {
sendResponse({ ok: false, error: "当前页面没有找到可联动的视频播放器。" });
return false;
}
const nextTime = Math.max(0, Number.isFinite(seconds) ? seconds : 0);
const wasPaused = Boolean(video.paused);
video.currentTime = nextTime;
if (!wasPaused) {
video.play().catch(() => {});
}
if (state.readingViewOpen) {
state.readingManualScrollPauseUntil = 0;
state.readingNextScrollBehavior = "auto";
updateReaderFollowState();
syncReadingViewPlayback(true);
}
sendResponse({ ok: true, currentTime: nextTime });
return false;
}
return false;
});
}
function bindSettingsWatcher() {
if (state.settingsWatcherBound || !chrome.storage?.onChanged) {
return;
}
state.settingsWatcherBound = true;
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "sync" && areaName !== "local") {
return;
}
if (
!changes.enablePlayerAiQuickAction &&
!changes.playerAiQuickPrompt &&
!changes.readerTheme &&
!changes.readerFontScale &&
!changes.readerFontWeight &&
!changes.readerLetterSpacing &&
!changes.readerLineHeight &&
!changes.readerContentWidth &&
!changes.readerChapterWidthPx &&
!changes.readerTranscriptWidthPx &&
!changes.readerVideoHeightPx &&
!changes.readerChapterVisibility &&
!changes.readerTranscriptVisible &&
!changes.readerTimestampVisible &&
!changes.readerTranscriptMode
) {
return;
}
getSettings()
.then((settings) => {
const activeLayout = state.readingViewOpen
? {
chapterWidth: state.readingChapterWidthPx,
transcriptWidth: state.readingTranscriptWidthPx,
videoHeight: state.readingVideoHeightPx
}
: null;
state.settings = settings;
hydrateReaderStateFromSettings(settings);
// The open reader owns its live drag state. Storage notifications can
// arrive out of order when two resize handles are used in quick
// succession, so they must not restore an older column or video size.
if (activeLayout) {
state.readingChapterWidthPx = activeLayout.chapterWidth;
state.readingTranscriptWidthPx = activeLayout.transcriptWidth;
state.readingVideoHeightPx = activeLayout.videoHeight;
state.settings = {
...state.settings,
readerChapterWidthPx: activeLayout.chapterWidth,
readerTranscriptWidthPx: activeLayout.transcriptWidth,
readerVideoHeightPx: activeLayout.videoHeight
};
}
applyReadingViewPresentation();
schedulePlayerAiQuickActionSync();
})
.catch((error) => {
logWarn("[BOC] failed to refresh settings after storage change", error);
});
});
}
function buildUiHtml() {
return `
排版
${buildReaderStepperControl({
id: ids.readingFontScaleSelect,
title: "字号",
settingKey: "readerFontScale"
})}
${buildReaderStepperControl({
id: ids.readingFontWeightSelect,
title: "字重",
settingKey: "readerFontWeight"
})}
${buildReaderStepperControl({
id: ids.readingLetterSpacingSelect,
title: "字间距",
settingKey: "readerLetterSpacing"
})}
${buildReaderStepperControl({
id: ids.readingLineHeightSelect,
title: "行间距",
settingKey: "readerLineHeight"
})}
使用页面原生播放器联动章节和字幕。
`;
}
function bindUiEvents() {
const panel = byId(ids.panel);
const closeBtn = byId(ids.closeBtn);
const refreshBtn = byId(ids.refreshBtn);
const select = byId(ids.subtitleSelect);
const copyBtn = byId(ids.copyBtn);
const downloadBtn = byId(ids.downloadBtn);
const sendBtn = byId(ids.sendBtn);
const settingsBtn = byId(ids.settingsBtn);
const readingView = byId(ids.readingView);
const readingCloseBtn = byId(ids.readingCloseBtn);
const readingAutoScroll = byId(ids.readingAutoScroll);
const readingTranscriptVisible = byId(ids.readingTranscriptVisible);
const readingTimestampVisible = byId(ids.readingTimestampVisible);
const readingThemeSelect = byId(ids.readingThemeSelect);
const readingSettingsToggleBtn = byId(ids.readingSettingsBtn);
const readingFontScaleSelect = byId(ids.readingFontScaleSelect);
const readingFontWeightSelect = byId(ids.readingFontWeightSelect);
const readingLetterSpacingSelect = byId(ids.readingLetterSpacingSelect);
const readingLineHeightSelect = byId(ids.readingLineHeightSelect);
const readingDescriptionBtn = byId(ids.readingDescriptionBtn);
const chapterList = byId(ids.readingChapterList);
const transcriptList = byId(ids.readingTranscriptList);
closeBtn.addEventListener("click", () => panel.classList.remove("open"));
refreshBtn.addEventListener("click", refreshClip);
select.addEventListener("change", onSubtitleChange);
copyBtn.addEventListener("click", copyMarkdown);
downloadBtn.addEventListener("click", downloadSubtitle);
sendBtn.addEventListener("click", sendToObsidian);
settingsBtn.addEventListener("click", requestOpenOptions);
readingCloseBtn.addEventListener("click", () => {
if (isReaderMode()) {
replaceReaderModeUrl(stripReaderModeUrl(location.href));
}
closeReadingView();
});
readingAutoScroll.addEventListener("change", (event) => {
state.readingAutoScroll = Boolean(event.target.checked);
if (state.readingAutoScroll) {
state.readingManualScrollPauseUntil = 0;
syncReadingViewPlayback(true);
}
updateReaderFollowState();
});
readingTranscriptVisible.addEventListener("change", (event) => {
updateReaderPreferences({ readerTranscriptVisible: Boolean(event.target.checked) }, { persist: true });
const main = document.querySelector(".blr-reading-main");
if (main) {
main.style.display = event.target.checked ? "" : "none";
}
});
readingTimestampVisible.addEventListener("change", (event) => {
updateReaderPreferences({ readerTimestampVisible: Boolean(event.target.checked) }, { persist: true });
});
const readingChapterVisible = byId(ids.readingChapterVisible);
if (readingChapterVisible) {
readingChapterVisible.addEventListener("change", (event) => {
updateReaderPreferences({ readerChapterVisible: Boolean(event.target.checked) }, { persist: true });
});
}
readingThemeSelect.addEventListener("click", () => {
const themes = ["light", "dark", "paper"];
const current = state.readingTheme || "light";
const nextIndex = (themes.indexOf(current) + 1) % themes.length;
updateReaderPreferences({ readerTheme: themes[nextIndex] }, { persist: true });
readingThemeSelect.classList.add("is-active");
setTimeout(() => readingThemeSelect.classList.remove("is-active"), 300);
});
readingSettingsToggleBtn.addEventListener("click", (e) => {
e.stopPropagation();
state.readingSettingsExpanded = !state.readingSettingsExpanded;
renderReaderPanels();
});
readingDescriptionBtn.addEventListener("click", () => {
state.readingDescriptionExpanded = !state.readingDescriptionExpanded;
renderReadingInfoPanel();
});
bindReaderStepperControl(readingFontScaleSelect, "readerFontScale");
bindReaderStepperControl(readingFontWeightSelect, "readerFontWeight");
bindReaderStepperControl(readingLetterSpacingSelect, "readerLetterSpacing");
bindReaderStepperControl(readingLineHeightSelect, "readerLineHeight");
bindReaderResizeHandle(byId(ids.readingChapterResizeHandle), "chapter");
bindReaderResizeHandle(byId(ids.readingTranscriptResizeHandle), "transcript");
const readingSubtitleSelect = byId(ids.readingSubtitleSelect);
const readingTranscriptModeSelect = byId(ids.readingTranscriptModeSelect);
readingTranscriptModeSelect.addEventListener("change", (event) => {
updateReaderPreferences({ readerTranscriptMode: event.target.value }, { persist: true });
renderReadingView();
syncReadingViewPlayback(true);
});
readingSubtitleSelect.addEventListener("change", (event) => {
const option = event.target.options[event.target.selectedIndex];
const url = String(option?.value || "");
if (!url) return;
loadSubtitle(url, String(option.dataset.lang || "unknown"), state.fetchRunId, String(option.dataset.id || ""))
.then(() => {
renderReadingView();
syncReadingViewPlayback(true);
})
.catch((error) => {
logWarn("[BOC] failed to switch subtitle in reading view", error);
});
});
// Click outside settings panel to close
if (!state.readingDocumentClickBound) {
document.addEventListener("click", (e) => {
if (!state.readingSettingsExpanded) return;
const settingsPanel = document.getElementById(ids.readingSettingsPanel);
const settingsBtnEl = document.getElementById(ids.readingSettingsBtn);
if (!settingsPanel || !settingsBtnEl) {
return;
}
if (!settingsPanel.contains(e.target) && !settingsBtnEl.contains(e.target)) {
state.readingSettingsExpanded = false;
renderReaderPanels();
}
});
state.readingDocumentClickBound = true;
}
const handleReaderManualScroll = () => {
if (Date.now() <= state.readingProgrammaticScrollUntil) {
return;
}
noteManualReaderInteraction();
};
transcriptList.addEventListener("scroll", handleReaderManualScroll);
transcriptList.addEventListener("wheel", handleReaderManualScroll, { passive: true });
chapterList.addEventListener("wheel", handleReaderManualScroll, { passive: true });
chapterList.addEventListener("pointerdown", () => noteManualReaderInteraction(3500));
transcriptList.addEventListener("pointerdown", () => noteManualReaderInteraction(3500));
chapterList.addEventListener("click", onReadingChapterClick);
transcriptList.addEventListener("click", onReadingTranscriptClick);
readingView.addEventListener("transitionend", () => {
if (!state.readingViewOpen) {
stopReadingViewSync();
}
});
}
function startUrlWatcher() {
if (state.urlWatcherStarted) {
return;
}
state.urlWatcherStarted = true;
window.setInterval(() => {
const nextUrl = location.href;
const nextSignature = computeCurrentClipSignature();
if (nextSignature === state.currentClipSignature) {
return;
}
state.currentUrl = nextUrl;
state.currentClipSignature = nextSignature;
enforceNormalPageStateIfNeeded(nextUrl);
ensureUiReady();
resetClipState();
const shouldEnterReaderMode = isReaderMode(nextUrl);
if (!state.readingViewOpen && shouldEnterReaderMode) {
document.documentElement.setAttribute("data-blr-reader-mode", "1");
document.body.setAttribute("data-blr-reader-mode", "1");
renderReadingStatus("检测到阅读视图跳转,正在打开阅读模式...");
enterReaderMode().catch((error) => {
renderReadingStatus(`阅读视图启动失败:${getErrorMessage(error)}`);
});
return;
}
if (state.readingViewOpen || shouldEnterReaderMode) {
renderReadingStatus("检测到视频变化,正在自动刷新字幕...");
waitForVideoMetadata().then(() => {
refreshClip().catch((error) => {
if (!isStaleRunError(error)) {
renderReadingStatus(`自动刷新失败:${getErrorMessage(error)}`);
}
});
});
return;
}
setStatus("检测到页面变化,请点击“刷新抓取”加载当前视频字幕。");
}, 1200);
}
function resetClipState() {
state.bvid = "";
state.aid = "";
state.cid = "";
state.cidSource = "";
state.pageIndex = 1;
state.pageCount = 0;
state.pageTitle = "";
state.videoDuration = 0;
state.description = "";
state.title = "";
state.author = "";
state.uploadDate = "";
state.subtitles = [];
state.selectedSubtitleId = "";
state.selectedSubtitleUrl = "";
state.selectedSubtitleLang = "";
state.subtitleBody = [];
state.subtitleFetchState = "idle";
state.chapters = [];
state.hotComments = [];
state.markdown = "";
state.srt = "";
state.txt = "";
state.currentClipSignature = computeCurrentClipSignature();
stopReadingViewSync();
state.readingActiveSubtitleIndex = -1;
state.readingActiveChapterIndex = -1;
state.readingVideoEl = null;
stopReaderPlayerObserver();
renderMeta();
renderSubtitleSelect();
byId(ids.preview).value = "";
setMessage("");
if (state.readingViewOpen) {
renderReadingView();
renderReadingStatus("请先点击“刷新抓取”加载当前视频字幕。");
}
}
function clearSubtitleContentAfterFetchError() {
state.selectedSubtitleId = "";
state.selectedSubtitleUrl = "";
state.selectedSubtitleLang = "";
state.subtitleBody = [];
state.hotComments = [];
state.markdown = "";
state.srt = "";
state.txt = "";
state.readingActiveSubtitleIndex = -1;
renderMeta();
renderSubtitleSelect();
byId(ids.preview).value = "";
setMessage("");
}
async function refreshClip() {
const runId = ++state.fetchRunId;
try {
setBusyState(true);
setMessage("");
setStatus("正在抓取视频信息...");
state.subtitleFetchState = "loading";
if (state.readingViewOpen) {
renderReadingView();
}
state.settings = await getSettings();
ensureRunActive(runId);
state.bvid = extractBvid(location.href);
if (!state.bvid) {
throw new Error("当前页面不是标准 BV 视频地址,无法抓取字幕。");
}
const pageIndex = extractPageIndex(location.href);
const oid = extractOid(location.href);
const hasPageParam = hasExplicitPageParam(location.href);
const meta = await retryAsync(() => fetchVideoMeta(state.bvid), 2, 250);
ensureRunActive(runId);
// 调试:打印 API 返回的原始数据
logInfo("[BOC] raw meta data", {
meta,
defaultCid: meta.defaultCid,
pagesCount: (meta.pages || []).length
});
state.aid = meta.aid || "";
state.title = meta.title || readVideoTitle();
state.author = meta.author || readVideoAuthor();
state.uploadDate = meta.uploadDate || readUploadDate();
state.description = meta.description || readVideoDescription();
state.pageCount = Array.isArray(meta.pages) ? meta.pages.length : 0;
state.currentClipSignature = computeCurrentClipSignature();
let resolvedPageIndex = pageIndex;
if ((meta.pages || []).length > 1 && !hasPageParam) {
const pageIndexFromOid = pickPageIndexFromOid(meta.pages, oid);
if (pageIndexFromOid > 0) {
resolvedPageIndex = pageIndexFromOid;
logInfo("[BOC] resolved page index from oid", {
oid,
resolvedPageIndex
});
} else {
// B 站多分P中,P1 常见为无 ?p= 参数;watchlater 等页面可能改用 oid 标识当前分P。
resolvedPageIndex = 1;
logInfo("[BOC] multi-page video without p param or valid oid, fallback to P1", {
oid
});
}
}
const currentPage = pickPageFromPages(meta.pages, resolvedPageIndex);
state.pageIndex = resolvedPageIndex;
state.pageTitle = currentPage?.part || "";
state.cid = currentPage?.cid || pickCidFromPages(meta.pages, resolvedPageIndex, meta.defaultCid);
state.cidSource = "meta-pages";
state.videoDuration = pickDurationFromPages(meta.pages, resolvedPageIndex, meta.defaultDuration);
if (!(state.videoDuration > 0)) {
state.videoDuration = readRuntimeVideoDuration();
}
if (!(state.videoDuration > 0)) {
throw new Error("无法获取当前视频时长,已停止抓取以避免串到错误字幕。");
}
logInfo("[BOC] resolved video ids", {
url: location.href,
aid: state.aid,
bvid: state.bvid,
cid: state.cid,
cidSource: state.cidSource,
pageIndex: resolvedPageIndex,
videoDuration: state.videoDuration
});
setStatus("正在获取可用字幕...");
let subtitleBundle = await retryAsync(
() => fetchSubtitleBundle(state.bvid, state.cid, state.aid),
3,
500
);
ensureRunActive(runId);
state.subtitles = normalizeSubtitleTracks(subtitleBundle.tracks);
state.chapters = normalizeChapters(subtitleBundle.chapters);
logInfo(
"[BOC] chapters",
state.chapters.map((item) => ({
from: item.from,
to: item.to,
title: item.title
}))
);
logInfo(
"[BOC] subtitle tracks",
state.subtitles.map((item) => ({
id: item.id,
lan: item.lan,
lanDoc: item.lanDoc,
url: item.subtitleUrl
}))
);
// 无字幕时也允许进入阅读视图,只是字幕区域保持空态。
if (state.subtitles.length === 0) {
applyNoSubtitleState();
renderMeta();
renderSubtitleSelect();
if (state.readingViewOpen) {
moveReadingMainInline();
renderReadingView();
renderReadingStatus("当前视频无字幕。");
startReadingViewSync();
startReaderPlayerObserver();
syncReadingViewPlayback(true);
}
setStatus("当前视频无字幕。");
return;
}
// 显式点击“刷新抓取”时默认走网络,避免命中历史缓存导致字幕错位。
const forceRefresh = true;
const preferred = pickPreferredSubtitle(state.subtitles, {
previousId: state.selectedSubtitleId,
previousUrl: state.selectedSubtitleUrl,
previousLang: state.selectedSubtitleLang
});
if (!preferred) {
applyNoSubtitleState();
renderMeta();
renderSubtitleSelect();
if (state.readingViewOpen) {
moveReadingMainInline();
renderReadingView();
renderReadingStatus("当前视频无字幕。");
startReadingViewSync();
startReaderPlayerObserver();
syncReadingViewPlayback(true);
}
setStatus("当前视频无字幕。");
return;
}
const candidates = buildSubtitleCandidates(state.subtitles, preferred);
let selected = null;
try {
selected = await tryLoadSubtitleCandidates(candidates, runId, forceRefresh);
} catch (error) {
const message = getErrorMessage(error, "");
if (!message.includes("HTTP") && error?.code !== "SUBTITLE_DURATION_MISMATCH") {
throw error;
}
// Retry because subtitle signed URLs may expire quickly or hit rate limit.
subtitleBundle = await retryAsync(
() => fetchSubtitleBundle(state.bvid, state.cid, state.aid),
2,
500
);
ensureRunActive(runId);
state.subtitles = normalizeSubtitleTracks(subtitleBundle.tracks);
state.chapters = normalizeChapters(subtitleBundle.chapters);
const retryPreferred = pickPreferredSubtitle(state.subtitles, {
previousId: preferred.id,
previousUrl: preferred.subtitleUrl,
previousLang: preferred.lanDoc || preferred.lan || ""
});
if (!retryPreferred) {
throw error;
}
const retryCandidates = buildSubtitleCandidates(state.subtitles, retryPreferred);
selected = await tryLoadSubtitleCandidates(retryCandidates, runId, forceRefresh);
}
ensureRunActive(runId);
if (selected) {
logInfo("[BOC] selected subtitle track", {
id: selected.id,
lan: selected.lan,
lanDoc: selected.lanDoc
});
}
state.subtitleFetchState = "ready";
renderMeta();
renderSubtitleSelect();
if (state.readingViewOpen) {
moveReadingMainInline();
renderReadingView();
renderReadingStatus("抓取完成,阅读视图已同步最新字幕。");
startReadingViewSync();
startReaderPlayerObserver();
syncReadingViewPlayback(true);
}
setStatus("抓取完成,可以复制、下载或发送到 Obsidian。");
} catch (error) {
if (isStaleRunError(error)) {
return;
}
// 字幕正文与章节列表来自不同请求。字幕域名被拦截、网络失败或签名
// 过期时,保留已经成功取得的章节和当前视频元数据。
if (state.bvid && state.cid) {
clearSubtitleContentAfterFetchError();
state.subtitleFetchState = "error";
} else {
resetClipState();
state.subtitleFetchState = "error";
}
if (state.readingViewOpen) {
renderReadingView();
}
if (error?.code === "SUBTITLE_DURATION_MISMATCH") {
setStatus("抓取失败:未找到与当前视频时长匹配的字幕轨,可能该视频无可用字幕。");
return;
}
setStatus(`抓取失败:${getErrorMessage(error)}`);
} finally {
if (runId === state.fetchRunId) {
setBusyState(false);
}
}
}
async function onSubtitleChange(event) {
const value = event.target.value;
const option = event.target.options[event.target.selectedIndex];
const lang = option?.dataset.lang || "unknown";
const subtitleId = option?.dataset.id || "";
if (!value) {
return;
}
try {
setBusyState(true);
setStatus(`正在切换字幕:${lang}`);
setMessage("");
await loadSubtitle(value, lang, state.fetchRunId, subtitleId);
setStatus("字幕切换完成。");
} catch (error) {
if (isStaleRunError(error)) {
return;
}
setStatus(`切换字幕失败:${getErrorMessage(error)}`);
} finally {
setBusyState(false);
}
}
async function loadSubtitle(url, lang, runId = state.fetchRunId, subtitleId = "", forceRefresh = false) {
if (!url) {
throw new Error("字幕 URL 为空。");
}
const cacheKey = getSubtitleCacheKey({
bvid: state.bvid,
cid: state.cid,
subtitleId,
subtitleUrl: url,
lang
});
// 尝试从缓存读取
if (!forceRefresh) {
const cachedBody = await loadSubtitleFromCache(cacheKey);
if (cachedBody && Array.isArray(cachedBody) && cachedBody.length > 0) {
const cachedCheck = validateSubtitleByDuration(cachedBody, state.videoDuration);
if (!cachedCheck.ok) {
logWarn("[BOC] cached subtitle duration mismatch, clearing cache", {
cacheKey,
reason: cachedCheck.reason
});
await clearSubtitleCacheByKey(cacheKey);
} else {
logInfo("[BOC] using cached subtitle", { cacheKey, itemCount: cachedBody.length });
ensureRunActive(runId);
state.selectedSubtitleId = subtitleId ? String(subtitleId) : state.selectedSubtitleId;
state.selectedSubtitleUrl = url;
state.selectedSubtitleLang = lang;
state.subtitleBody = cachedBody;
state.subtitleFetchState = "ready";
await refreshDerivedContent();
if (state.readingViewOpen) {
renderReadingView();
syncReadingViewPlayback(true);
}
return;
}
}
}
// 从网络获取
const subtitle = await fetchSubtitleBody(url);
ensureRunActive(runId);
const body = Array.isArray(subtitle.body) ? subtitle.body : [];
if (body.length === 0) {
throw new Error("字幕文件为空。");
}
const durationCheck = validateSubtitleByDuration(body, state.videoDuration);
if (!durationCheck.ok) {
const mismatchError = new Error("字幕时长与当前视频不匹配。");
mismatchError.code = "SUBTITLE_DURATION_MISMATCH";
mismatchError.details = durationCheck;
throw mismatchError;
}
// 存入缓存
await saveSubtitleToCache(cacheKey, body);
state.selectedSubtitleId = subtitleId ? String(subtitleId) : state.selectedSubtitleId;
state.selectedSubtitleUrl = url;
state.selectedSubtitleLang = lang;
state.subtitleBody = body;
state.subtitleFetchState = "ready";
await refreshDerivedContent();
if (state.readingViewOpen) {
renderReadingView();
syncReadingViewPlayback(true);
}
}
function getSubtitleCacheKey({ bvid, cid, subtitleId = "", subtitleUrl = "", lang = "" }) {
const sourceKey = buildSubtitleSourceKey(subtitleId, subtitleUrl, lang);
return `${CACHE_KEY_PREFIX}${bvid}_${cid}_${sourceKey}`;
}
function buildSubtitleSourceKey(subtitleId, subtitleUrl, lang) {
const id = String(subtitleId || "").trim();
if (id) {
return `id_${id}`;
}
const normalizedUrl = normalizeSubtitleUrlForCache(subtitleUrl);
if (normalizedUrl) {
return `url_${normalizedUrl}`;
}
return `lang_${String(lang || "").trim().toLowerCase() || "unknown"}`;
}
function normalizeSubtitleUrlForCache(url) {
const text = String(url || "").trim();
if (!text) {
return "";
}
try {
const parsed = new URL(text);
const path = parsed.pathname.replace(/[^\w/.-]+/g, "_");
return `${parsed.hostname}${path}`;
} catch {
return text.replace(/[^\w/.-]+/g, "_");
}
}
async function loadSubtitleFromCache(cacheKey) {
try {
const result = await chrome.storage.local.get(cacheKey);
return result[cacheKey]?.body || null;
} catch {
return null;
}
}
async function saveSubtitleToCache(cacheKey, body) {
try {
await chrome.storage.local.set({
[cacheKey]: {
body,
timestamp: Date.now()
}
});
} catch (error) {
logWarn("[BOC] failed to save subtitle cache", error);
}
}
async function clearSubtitleCacheByKey(cacheKey) {
try {
await chrome.storage.local.remove(cacheKey);
} catch (error) {
logWarn("[BOC] failed to clear subtitle cache by key", { cacheKey, error });
}
}
async function clearSubtitleCache(bvid, cid, lang) {
const cacheKey = getSubtitleCacheKey({ bvid, cid, lang });
try {
await chrome.storage.local.remove(cacheKey);
logInfo("[BOC] cleared subtitle cache", { cacheKey });
} catch (error) {
logWarn("[BOC] failed to clear subtitle cache", error);
}
}
function renderMeta() {
const meta = byId(ids.meta);
if (!state.bvid) {
meta.innerHTML = '
尚未抓取视频信息
';
return;
}
const subtitleCount = state.subtitles.length;
meta.innerHTML = `
标题:${escapeHtml(state.title)}
URL:${escapeHtml(cleanVideoUrl())}
作者:${escapeHtml(state.author || "未知")}
日期:${escapeHtml(state.uploadDate || "未知")}
字幕轨:${subtitleCount}
`;
}
function renderSubtitleSelect() {
const select = byId(ids.subtitleSelect);
const subtitles = state.subtitles || [];
if (subtitles.length === 0) {
select.innerHTML = '';
select.disabled = true;
return;
}
select.innerHTML = subtitles
.map((item) => {
const selectedById =
state.selectedSubtitleId && String(item.id) === String(state.selectedSubtitleId);
const selectedByUrl = item.subtitleUrl === state.selectedSubtitleUrl;
const selected = selectedById || selectedByUrl ? "selected" : "";
const label = item.lanDoc || item.lan || "unknown";
const isAi = isAiSubtitle(item);
const aiTag = isAi ? " [AI自动]" : "";
const optionLabel = `${label}${aiTag}`;
return ``;
})
.join("");
select.disabled = false;
}
function renderReadingSubtitleSelect() {
const select = byId(ids.readingSubtitleSelect);
const subtitles = state.subtitles || [];
if (subtitles.length === 0) {
select.innerHTML = '';
select.disabled = true;
return;
}
select.innerHTML = subtitles
.map((item) => {
const selectedById =
state.selectedSubtitleId && String(item.id) === String(state.selectedSubtitleId);
const selectedByUrl = item.subtitleUrl === state.selectedSubtitleUrl;
const selected = selectedById || selectedByUrl ? "selected" : "";
const label = item.lanDoc || item.lan || "unknown";
const isAi = isAiSubtitle(item);
const aiTag = isAi ? " [AI]" : "";
const optionLabel = `${label}${aiTag}`;
return ``;
})
.join("");
select.disabled = false;
}
function getPopupPayload() {
const subtitleOptions = (state.subtitles || []).map((item) => {
const label = item.lanDoc || item.lan || "unknown";
const isAi = isAiSubtitle(item);
const selectedById =
state.selectedSubtitleId && String(item.id) === String(state.selectedSubtitleId);
const selectedByUrl = item.subtitleUrl === state.selectedSubtitleUrl;
return {
id: String(item.id || ""),
url: item.subtitleUrl,
lang: label,
isAi,
selected: selectedById || selectedByUrl
};
});
return {
contentVersion: READER_VERSION,
url: cleanVideoUrl(),
title: state.title || "",
author: state.author || "",
uploadDate: state.uploadDate || "",
tags: String(state.settings?.tags || ""),
status: state.statusText || "",
message: state.messageText || "",
subtitlePreview: buildSubtitlePreview(state.subtitleBody || [], state.settings || DEFAULT_SETTINGS),
markdown: state.markdown || "",
srt: state.srt || "",
txt: state.txt || "",
downloadFormat: normalizeDownloadFormat(state.settings?.downloadFormat),
subtitleOptions
};
}
async function copyMarkdown() {
state.settings = await getSettings();
await refreshDerivedContent();
if (!state.markdown) {
setMessage("没有可复制的内容,请先刷新抓取。");
return;
}
try {
await navigator.clipboard.writeText(state.markdown);
setMessage("Markdown 已复制到剪贴板。");
} catch (error) {
setMessage(`复制失败:${getErrorMessage(error)}`);
}
}
async function downloadSubtitle() {
state.settings = await getSettings();
rebuildDerivedContent();
const format = normalizeDownloadFormat(state.settings?.downloadFormat);
const content = format === "txt" ? state.txt : state.srt;
if (!content) {
setMessage("没有可下载的字幕,请先刷新抓取。");
return;
}
const safeTitle = sanitizeFileName(state.title || state.bvid || "bilibili-subtitle");
const langSuffix = sanitizeFileName(state.selectedSubtitleLang || "subtitle") || "subtitle";
const filename = `${safeTitle}.${langSuffix}.${format}`;
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
setMessage(`已下载:${filename}`);
}
async function sendToObsidian() {
state.settings = await getSettings();
await refreshDerivedContent();
if (!state.markdown) {
setMessage("没有可发送内容,请先刷新抓取。");
return;
}
const filename = buildNoteFilename(state);
const folder = resolveFolderTemplate(state.settings.noteFolder || "", state);
const filepath = folder ? `${folder}/${filename}` : filename;
const baseUrl = String(state.settings.obsidianApiBaseUrl || "").trim();
const apiKey = String(state.settings.obsidianApiKey || "").trim();
if (!baseUrl || !apiKey) {
setMessage("请先在设置中填写 Obsidian Local REST API 地址和 API Key。");
requestOpenOptions();
return;
}
try {
const exists = await checkObsidianNoteExists(baseUrl, apiKey, filepath);
if (exists) {
const shouldOverwrite = await confirmOverwriteNote(filepath);
if (!shouldOverwrite) {
setMessage("已取消保存,原笔记未被覆盖。");
return;
}
}
await writeNoteByLocalApi(baseUrl, apiKey, filepath, state.markdown);
setMessage(`已写入 Obsidian:${filepath}`);
} catch (error) {
if (isExtensionContextInvalidated(error)) {
setMessage("扩展刚刚更新,请刷新当前页面后重试。");
return;
}
setMessage(`写入失败:${getErrorMessage(error)}`);
}
}
async function checkObsidianNoteExists(baseUrl, apiKey, filepath) {
const resp = await sendRuntimeMessage({
type: "obsidian-note-exists",
baseUrl,
apiKey,
filepath
});
if (!resp?.ok) {
throw new Error(toReadableText(resp?.error, "Local API 检查失败"));
}
return Boolean(resp.exists);
}
async function writeNoteByLocalApi(baseUrl, apiKey, filepath, content) {
const resp = await sendRuntimeMessage({
type: "write-obsidian-note",
baseUrl,
apiKey,
filepath,
content
});
if (!resp?.ok) {
throw new Error(toReadableText(resp?.error, "Local API 写入失败"));
}
}
function confirmOverwriteNote(filepath) {
return new Promise((resolve) => {
const existing = document.querySelector(".blr-confirm-overlay");
if (existing) {
existing.remove();
}
const overlay = document.createElement("div");
overlay.className = "blr-confirm-overlay";
overlay.innerHTML = `
`;
overlay.querySelector(".blr-confirm-path").textContent = String(filepath || "");
const cleanup = (value) => {
overlay.remove();
document.removeEventListener("keydown", onKeydown, true);
resolve(value);
};
const onKeydown = (event) => {
if (event.key === "Escape") {
event.preventDefault();
cleanup(false);
}
};
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
cleanup(false);
}
});
overlay.querySelector(".blr-confirm-cancel")?.addEventListener("click", () => cleanup(false));
overlay.querySelector(".blr-confirm-primary")?.addEventListener("click", () => cleanup(true));
document.addEventListener("keydown", onKeydown, true);
document.body.appendChild(overlay);
overlay.querySelector(".blr-confirm-primary")?.focus();
});
}
function setBusyState(disabled) {
byId(ids.copyBtn).disabled = disabled;
byId(ids.downloadBtn).disabled = disabled;
byId(ids.sendBtn).disabled = disabled;
byId(ids.refreshBtn).disabled = disabled;
byId(ids.settingsBtn).disabled = disabled;
byId(ids.subtitleSelect).disabled = disabled || state.subtitles.length === 0;
}
function setStatus(text) {
state.statusText = String(text || "");
byId(ids.status).textContent = state.statusText;
}
function setMessage(text) {
state.messageText = String(text || "");
byId(ids.message).textContent = state.messageText;
}
function applyNoSubtitleState() {
state.selectedSubtitleId = "";
state.selectedSubtitleUrl = "";
state.selectedSubtitleLang = "";
state.subtitleBody = [];
state.subtitleFetchState = "empty";
state.hotComments = [];
state.markdown = "";
state.srt = "";
state.txt = "";
byId(ids.preview).value = "";
}
function cleanupReaderFloatingArtifacts(playerHost = state.readingPlayerHost) {
if (document.pictureInPictureElement) {
document.exitPictureInPicture().catch(() => {});
}
dismissReaderMiniPlayer(playerHost);
const runtimeHost = findReaderPlayerHost(getRuntimeVideoElement());
if (runtimeHost && runtimeHost !== playerHost) {
dismissReaderMiniPlayer(runtimeHost);
}
}
async function enterReaderMode() {
const readingView = byId(ids.readingView);
state.readingViewOpen = true;
state.readingNativePageMode = true;
document.body.setAttribute("data-blr-reading-active", "1");
hydrateReaderStateFromSettings(state.settings);
applyReadingViewPresentation();
alignReaderViewportToPlayer();
await sleep(0);
openReaderViewShell(readingView);
applyReaderPageFocus();
renderReadingView();
const earlyPlayerHost = findReaderPlayerHost(getRuntimeVideoElement());
if (earlyPlayerHost) {
earlyPlayerHost.setAttribute("data-blr-reader-fading", "1");
}
await sleep(0);
// Try to mount player, with more retries for slower pages (like watch later)
const mounted = await ensureReaderPlayerMounted({ retries: 50, delayMs: 150, forceLayout: true });
const mountedPlayerHost = state.readingPlayerHost || earlyPlayerHost;
if (mountedPlayerHost) {
mountedPlayerHost.removeAttribute("data-blr-reader-fading");
}
if (!mounted) {
// Don't throw - keep UI open and keep retrying in background
renderReadingStatus("正在等待视频播放器就绪...");
scheduleReaderPlayerRetry();
return;
}
finishEnterReaderMode();
}
function scheduleReaderPlayerRetry() {
if (state.readingPlayerRetryTimer) {
window.clearTimeout(state.readingPlayerRetryTimer);
state.readingPlayerRetryTimer = 0;
}
// Keep trying to mount player in background
const tryMount = async () => {
state.readingPlayerRetryTimer = 0;
if (!state.readingViewOpen || !isReaderMode()) return;
const mounted = await ensureReaderPlayerMounted({ retries: 10, delayMs: 200, forceLayout: true });
const retryHost = state.readingPlayerHost;
if (retryHost) {
retryHost.removeAttribute("data-blr-reader-fading");
}
if (mounted) {
finishEnterReaderMode();
} else if (state.readingViewOpen) {
state.readingPlayerRetryTimer = window.setTimeout(tryMount, 500);
}
};
state.readingPlayerRetryTimer = window.setTimeout(tryMount, 500);
}
function finishEnterReaderMode() {
if (!state.readingViewOpen || !isReaderMode()) return;
alignReaderViewportToPlayer();
moveReadingMainInline();
scheduleReaderMiniPlayerDismiss();
maybeRefreshReaderSubtitleInBackground();
syncReaderModeAfterMount();
settleReaderModePresentation();
bindReaderHeaderActionsHover();
}
function openReaderViewShell(readingView = byId(ids.readingView)) {
if (!readingView) {
return;
}
readingView.classList.add("open", "reader-page");
readingView.setAttribute("aria-hidden", "false");
setReadingViewReady(false);
renderReadingStatus("正在准备播放器和字幕...");
}
function maybeRefreshReaderSubtitleInBackground() {
if (state.subtitleBody.length) {
return;
}
waitForVideoMetadata().then(() => {
refreshClip().catch((error) => {
if (!isStaleRunError(error)) {
renderReadingStatus(`字幕加载失败:${getErrorMessage(error)}`);
}
});
});
}
function waitForVideoMetadata(timeoutMs = 5000) {
return new Promise((resolve) => {
const start = Date.now();
const check = () => {
const video = getRuntimeVideoElement();
const duration = Number(video?.duration);
const ready = video && Number.isFinite(duration) && duration > 0;
if (ready || Date.now() - start >= timeoutMs) {
resolve();
return;
}
window.setTimeout(check, 150);
};
check();
});
}
function syncReaderModeAfterMount() {
startReadingViewSync();
startReaderPlayerObserver();
layoutReaderPlayerHost();
syncReadingViewPlayback(true);
updateReaderFollowState();
}
function settleReaderModePresentation() {
if (!isReaderPresentationStable()) {
setReadingViewReady(false);
renderReadingStatus("正在稳定播放器布局...");
scheduleReaderPlayerRetry();
return false;
}
setReadingViewReady(true);
renderReadingStatus("阅读视图已就绪,播放视频时字幕会自动高亮。");
return true;
}
async function ensureReaderPlayerMounted({ retries = 1, delayMs = 100, forceLayout = false } = {}) {
for (let attempt = 0; attempt < retries; attempt += 1) {
const video = getRuntimeVideoElement();
const playerHost = findReaderPlayerHost(video);
if (video && playerHost) {
const previousHost = state.readingPlayerHost;
const previousVideo = state.readingVideoEl;
video.controls = false;
video.removeAttribute("controls");
video.disablePictureInPicture = true;
video.setAttribute("disablepictureinpicture", "");
video.removeAttribute("autopictureinpicture");
state.readingPlayerHost = playerHost;
const miniPlayerClosed = dismissReaderMiniPlayer(playerHost);
if (miniPlayerClosed) {
await sleep(120);
}
const activeHost = findReaderPlayerHost(video) || playerHost;
state.readingPlayerHost = activeHost;
normalizeReaderPlayerContainer(activeHost);
if (state.readingNativePageMode) {
clearNativeReaderFloatingStyles(activeHost);
if (hasNativeReaderPlayerLayoutIssue(activeHost)) {
normalizeReaderPlayerContainer(activeHost);
clearNativeReaderFloatingStyles(activeHost);
}
}
if (previousHost && previousHost !== activeHost) {
setReaderPlayerControlsVisible(false, previousHost);
cleanupReaderPlayerHostNode(previousHost);
}
if (previousVideo !== video) {
state.readingVideoEventsBound = false;
}
activeHost.classList.add("blr-reader-player-host");
bindReadingViewVideo(video);
bindReaderPlayerControlsHover(activeHost);
bindReaderLayout();
if (
forceLayout ||
previousHost !== activeHost ||
attempt > 0 ||
miniPlayerClosed ||
(state.readingNativePageMode && hasNativeReaderPlayerLayoutIssue(activeHost))
) {
layoutReaderPlayerHost();
if (state.readingNativePageMode && hasNativeReaderPlayerLayoutIssue(activeHost)) {
normalizeReaderPlayerContainer(activeHost);
clearNativeReaderFloatingStyles(activeHost);
layoutReaderPlayerHost();
}
}
if (state.readingNativePageMode && !isWatchlaterPage()) {
await ensureReaderPlayerControlsRecovered(activeHost, {
reason: attempt > 0 ? "mount-retry" : "mount"
});
queueEnsureReaderPlayerControlsRecovered({
reason: attempt > 0 ? "post-mount-retry" : "post-mount",
delayMs: 220,
minIntervalMs: 240
});
}
if (document.pictureInPictureElement) {
document.exitPictureInPicture().catch(() => {});
}
return true;
}
await sleep(delayMs);
}
return false;
}
function queueEnsureReaderPlayerMounted() {
if (!state.readingViewOpen || !isReaderMode() || state.readingPlayerMountTimer) {
return;
}
state.readingPlayerMountTimer = window.setTimeout(() => {
state.readingPlayerMountTimer = 0;
ensureReaderPlayerMounted({ retries: 12, delayMs: 120, forceLayout: true }).catch((error) => {
logWarn("[BOC] ensure reader player mounted failed", error);
});
}, 60);
}
function findReaderPlayerHost(video) {
if (!video) {
return null;
}
return (
video.closest(".bpx-player-container") ||
video.closest(".bpx-player-video-area") ||
video.closest("#bilibili-player") ||
video.parentElement
);
}
function closeReadingView() {
cleanupReaderFloatingArtifacts();
state.readingViewOpen = false;
state.readingNativePageMode = false;
state.readingViewReady = false;
state.readingSettingsExpanded = false;
state.readingManualScrollPauseUntil = 0;
state.readingProgrammaticScrollUntil = 0;
state.readingNextScrollBehavior = "smooth";
if (state.readingPlayerRetryTimer) {
window.clearTimeout(state.readingPlayerRetryTimer);
state.readingPlayerRetryTimer = 0;
}
const readingView = byId(ids.readingView);
readingView.classList.remove("open", "reader-page");
readingView.setAttribute("aria-hidden", "true");
readingView.setAttribute("data-blr-reader-ready", "0");
readingView.removeAttribute("data-blr-reader-follow");
document.body.removeAttribute("data-blr-reading-active");
document.documentElement.removeAttribute("data-blr-reader-mode");
document.body.removeAttribute("data-blr-reader-mode");
document.documentElement.removeAttribute("data-blr-reader-theme");
document.documentElement.removeAttribute("data-blr-reader-font-scale");
document.documentElement.removeAttribute("data-blr-reader-font-weight");
document.documentElement.removeAttribute("data-blr-reader-letter-spacing");
document.documentElement.removeAttribute("data-blr-reader-line-height");
document.documentElement.removeAttribute("data-blr-reader-content-width");
document.documentElement.removeAttribute("data-blr-reader-chapter-visibility");
document.documentElement.removeAttribute("data-blr-reader-has-chapters");
document.documentElement.removeAttribute("data-blr-reader-transcript-visible");
document.documentElement.removeAttribute("data-blr-reader-timestamp-visible");
document.documentElement.removeAttribute("data-blr-reader-transcript-mode");
document.body.removeAttribute("data-blr-reader-theme");
document.body.removeAttribute("data-blr-reader-font-scale");
document.body.removeAttribute("data-blr-reader-font-weight");
document.body.removeAttribute("data-blr-reader-letter-spacing");
document.body.removeAttribute("data-blr-reader-line-height");
document.body.removeAttribute("data-blr-reader-content-width");
document.body.removeAttribute("data-blr-reader-chapter-visibility");
document.body.removeAttribute("data-blr-reader-has-chapters");
document.body.removeAttribute("data-blr-reader-transcript-visible");
document.body.removeAttribute("data-blr-reader-timestamp-visible");
document.body.removeAttribute("data-blr-reader-transcript-mode");
document.body.removeAttribute("data-blr-reader-resizing");
const pageReaderEntry = document.getElementById("blr-page-reader-entry");
if (pageReaderEntry) {
pageReaderEntry.disabled = false;
pageReaderEntry.classList.remove("is-loading");
}
[document.documentElement, document.body, readingView].forEach((node) => {
node.style.removeProperty("--blr-reader-rail-width");
node.style.removeProperty("--blr-reader-transcript-width");
node.style.removeProperty("--blr-reader-center-offset");
node.style.removeProperty("--blr-reader-main-width");
});
readingView.style.removeProperty("--blr-reader-player-left");
readingView.style.removeProperty("--blr-reader-player-bottom");
readingView.style.removeProperty("--blr-reader-player-width");
restoreReadingMainInline();
stopReadingViewSync();
unbindReaderLayout();
cleanupReaderPlayerHost();
clearReaderPageFocus();
const sendingBar = document.querySelector(".bpx-player-sending-bar");
if (sendingBar) {
sendingBar.setAttribute("data-blr-reader-hide-sending-bar", "1");
sendingBar.style.setProperty("display", "none", "important");
window.setTimeout(() => {
sendingBar.style.removeProperty("display");
sendingBar.removeAttribute("data-blr-reader-hide-sending-bar");
}, 200);
}
window.setTimeout(() => cleanupReaderFloatingArtifacts(), 40);
window.setTimeout(() => cleanupReaderFloatingArtifacts(), 220);
}
function renderReadingView() {
const titleNode = document.querySelector(".blr-reading-title");
const metaNode = byId(ids.readingMeta);
const chapterList = byId(ids.readingChapterList);
const transcriptList = byId(ids.readingTranscriptList);
const chapters = normalizeChapters(state.chapters || []);
const body = Array.isArray(state.subtitleBody) ? state.subtitleBody : [];
const transcriptItems = getReadingTranscriptItems();
const withHours = shouldShowHoursInNote(state, body);
const hasChapters = chapters.length > 0;
if (titleNode) {
titleNode.textContent = state.title || "B站字幕阅读";
}
if (metaNode) {
metaNode.textContent = buildReadingMetaLine();
}
if (chapters.length === 0) {
chapterList.innerHTML = '当前视频没有章节。
';
} else {
chapterList.innerHTML = chapters
.map(
(item, index) => `
`
)
.join("");
}
if (transcriptItems.length === 0) {
transcriptList.innerHTML = `${escapeHtml(
getReadingTranscriptPlaceholderText()
)}
`;
} else if (state.readingTranscriptMode === "complete") {
transcriptList.innerHTML = `
${transcriptItems
.map(
(item) => `
`
)
.join(" ")}
`;
transcriptList.insertAdjacentHTML(
"beforeend",
``
);
} else {
transcriptList.innerHTML = transcriptItems
.map(
(item) => `
`
)
.join("");
transcriptList.insertAdjacentHTML(
"beforeend",
``
);
}
updateReaderChapterPresence(hasChapters);
renderReadingInfoPanel();
renderReadingSubtitleSelect();
renderReaderPanels();
applyReadingViewPresentation();
updateReadingTranscriptTailSpacer();
state.readingActiveSubtitleIndex = -1;
state.readingActiveChapterIndex = -1;
}
function getReadingTranscriptPlaceholderText() {
if (state.subtitleFetchState === "loading") {
return "正在加载字幕...";
}
if (state.subtitleFetchState === "error") {
return "字幕加载失败,请刷新重试。";
}
return "当前视频无字幕。";
}
function getReadingTranscriptItems(body = state.subtitleBody) {
return (Array.isArray(body) ? body : [])
.map((item, index) => ({
index,
from: Number(item?.from || 0) || 0,
to: Number(item?.to || 0) || 0,
content: String(item?.content || "").trim()
}))
.filter((item) => item.content);
}
function updateReadingTranscriptTailSpacer() {
const spacer = document.getElementById(ids.readingTranscriptTailSpacer);
if (!spacer) {
return;
}
const inlineHost = document.getElementById("blr-reading-inline-host");
const transcriptList = document.getElementById(ids.readingTranscriptList);
const hostHeight = inlineHost?.clientHeight || transcriptList?.clientHeight || 0;
const spacerHeight = Math.max(hostHeight, Math.round(window.innerHeight * 0.92), 320);
spacer.style.height = `${spacerHeight}px`;
}
function hydrateReaderStateFromSettings(settings = state.settings) {
state.readingTheme = normalizeReaderTheme(settings?.readerTheme);
state.readingFontScale = normalizeReaderFontScale(settings?.readerFontScale);
state.readingFontWeight = normalizeReaderFontWeight(settings?.readerFontWeight);
state.readingLetterSpacing = normalizeReaderLetterSpacing(settings?.readerLetterSpacing ?? settings?.readerLineHeight);
state.readingLineHeight = normalizeReaderLineHeight(settings?.readerLineHeight);
state.readingContentWidth = normalizeReaderContentWidth(settings?.readerContentWidth);
state.readingChapterWidthPx = normalizeReaderColumnWidth(settings?.readerChapterWidthPx, 220, 140, 360);
state.readingTranscriptWidthPx = normalizeReaderColumnWidth(settings?.readerTranscriptWidthPx, 440, 280, 720);
state.readingVideoHeightPx = normalizeReaderVideoHeight(settings?.readerVideoHeightPx);
state.readingChapterVisible = settings?.readerChapterVisible !== undefined ? Boolean(settings.readerChapterVisible) : true;
state.readingTranscriptVisible = normalizeReaderTranscriptVisible(settings?.readerTranscriptVisible);
state.readingTimestampVisible = normalizeReaderTimestampVisible(settings?.readerTimestampVisible);
state.readingTranscriptMode = normalizeReaderTranscriptMode(settings?.readerTranscriptMode);
}
function applyReadingViewPresentation() {
const readingView = byId(ids.readingView);
readingView.dataset.theme = state.readingTheme;
readingView.dataset.fontScale = state.readingFontScale;
readingView.dataset.fontWeight = state.readingFontWeight;
readingView.dataset.letterSpacing = state.readingLetterSpacing;
readingView.dataset.lineHeight = state.readingLineHeight;
readingView.dataset.contentWidth = state.readingContentWidth;
readingView.dataset.chapterVisibility = state.readingChapterVisible ? "auto" : "hide";
readingView.dataset.transcriptVisible = state.readingTranscriptVisible ? "1" : "0";
readingView.dataset.timestampVisible = state.readingTimestampVisible ? "1" : "0";
readingView.dataset.transcriptMode = state.readingTranscriptMode;
document.documentElement.dataset.blrReaderTheme = state.readingTheme;
document.documentElement.dataset.blrReaderFontScale = state.readingFontScale;
document.documentElement.dataset.blrReaderFontWeight = state.readingFontWeight;
document.documentElement.dataset.blrReaderLetterSpacing = state.readingLetterSpacing;
document.documentElement.dataset.blrReaderLineHeight = state.readingLineHeight;
document.documentElement.dataset.blrReaderContentWidth = state.readingContentWidth;
document.documentElement.dataset.blrReaderChapterVisibility = state.readingChapterVisible ? "auto" : "hide";
document.documentElement.dataset.blrReaderTranscriptVisible = state.readingTranscriptVisible ? "1" : "0";
document.documentElement.dataset.blrReaderTimestampVisible = state.readingTimestampVisible ? "1" : "0";
document.documentElement.dataset.blrReaderTranscriptMode = state.readingTranscriptMode;
document.body.dataset.blrReaderTheme = state.readingTheme;
document.body.dataset.blrReaderFontScale = state.readingFontScale;
document.body.dataset.blrReaderFontWeight = state.readingFontWeight;
document.body.dataset.blrReaderLetterSpacing = state.readingLetterSpacing;
document.body.dataset.blrReaderLineHeight = state.readingLineHeight;
document.body.dataset.blrReaderContentWidth = state.readingContentWidth;
document.body.dataset.blrReaderChapterVisibility = state.readingChapterVisible ? "auto" : "hide";
document.body.dataset.blrReaderTranscriptVisible = state.readingTranscriptVisible ? "1" : "0";
document.body.dataset.blrReaderTimestampVisible = state.readingTimestampVisible ? "1" : "0";
document.body.dataset.blrReaderTranscriptMode = state.readingTranscriptMode;
applyReaderColumnLayout();
const readingChapterVisibleEl = byId(ids.readingChapterVisible);
if (readingChapterVisibleEl) {
readingChapterVisibleEl.checked = state.readingChapterVisible;
}
const main = document.querySelector(".blr-reading-main");
if (main) {
main.style.display = state.readingTranscriptVisible ? "" : "none";
}
const inlineHost = document.getElementById("blr-reading-inline-host");
if (inlineHost) {
const leftContainer = document.querySelector(".left-container");
const bgColor = leftContainer ? getComputedStyle(leftContainer).backgroundColor : "";
if (state.readingTranscriptVisible) {
inlineHost.style.border = "";
inlineHost.style.background = "";
inlineHost.style.marginTop = "";
inlineHost.style.boxShadow = "";
inlineHost.style.borderRadius = "";
} else {
inlineHost.style.border = "none";
inlineHost.style.background = bgColor;
inlineHost.style.marginTop = "0";
inlineHost.style.boxShadow = "none";
inlineHost.style.borderRadius = "0";
}
}
}
function updateReaderChapterPresence(hasChapters) {
const value = hasChapters ? "1" : "0";
const readingView = byId(ids.readingView);
readingView.dataset.hasChapters = value;
document.documentElement.dataset.blrReaderHasChapters = value;
document.body.dataset.blrReaderHasChapters = value;
}
function getToggleLabel(key, value) {
const labels = {
fontScale: { xs: "最小", s: "偏小", m: "标准", l: "偏大", xl: "最大" },
fontWeight: { light: "最细", regular: "偏细", normal: "标准", semibold: "偏粗", bold: "最粗" },
letterSpacing: { tighter: "最紧", tight: "偏紧", normal: "标准", relaxed: "偏松", loose: "最松" },
lineHeight: { compact: "最紧", tight: "偏紧", normal: "标准", relaxed: "偏松", loose: "最松" },
contentWidth: { compact: "最窄", narrow: "偏窄", medium: "标准", wide: "偏宽", full: "最宽" }
};
return labels[key]?.[value] || "标准";
}
function getReaderStepperConfig(settingKey) {
const configs = {
readerFontScale: {
options: ["xs", "s", "m", "l", "xl"],
labelKey: "fontScale",
getCurrent: () => state.readingFontScale,
buildPayload: (value) => ({ readerFontScale: value })
},
readerFontWeight: {
options: ["light", "regular", "normal", "semibold", "bold"],
labelKey: "fontWeight",
getCurrent: () => state.readingFontWeight,
buildPayload: (value) => ({ readerFontWeight: value })
},
readerLetterSpacing: {
options: ["tighter", "tight", "normal", "relaxed", "loose"],
labelKey: "letterSpacing",
getCurrent: () => state.readingLetterSpacing,
buildPayload: (value) => ({ readerLetterSpacing: value })
},
readerLineHeight: {
options: ["compact", "tight", "normal", "relaxed", "loose"],
labelKey: "lineHeight",
getCurrent: () => state.readingLineHeight,
buildPayload: (value) => ({ readerLineHeight: value })
},
readerContentWidth: {
options: ["compact", "narrow", "medium", "wide", "full"],
labelKey: "contentWidth",
getCurrent: () => state.readingContentWidth,
buildPayload: (value) => ({ readerContentWidth: value })
}
};
return configs[settingKey] || null;
}
function buildReaderStepperControl({
id,
title,
settingKey
}) {
const config = getReaderStepperConfig(settingKey);
if (!config) {
return "";
}
return `
${escapeHtml(title)}
${config.options
.map(
(option, index) => `
`
)
.join("")}
`;
}
function bindReaderStepperControl(node, settingKey) {
if (!node || node.dataset.blrBound === "1") {
return;
}
node.addEventListener("click", (event) => {
const button = event.target.closest("[data-value]");
if (!button) {
return;
}
setReaderPreference(settingKey, button.dataset.value || "");
});
node.dataset.blrBound = "1";
}
function bindReaderResizeHandle(node, side) {
if (!node || node.dataset.blrBound === "1") return;
node.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || window.innerWidth <= 1180) return;
event.preventDefault();
node.setPointerCapture?.(event.pointerId);
document.body.dataset.blrReaderResizing = side;
const playerRect = getReaderPlayerWrapNode()?.getBoundingClientRect?.();
const move = (moveEvent) => {
const pagePadding = getReaderPagePaddingPx();
const halfGap = Math.min(24, Math.max(16, window.innerWidth * 0.014)) / 2;
if (side === "chapter") {
if (!playerRect) return;
const maxHeight = Math.max(240, window.innerHeight - playerRect.top - 148);
state.readingVideoHeightPx = Math.round(
Math.min(maxHeight, Math.max(240, moveEvent.clientY - playerRect.top))
);
} else {
state.readingTranscriptWidthPx = normalizeReaderColumnWidth(
window.innerWidth - pagePadding - moveEvent.clientX - halfGap,
state.readingTranscriptWidthPx,
280,
720
);
}
applyReaderColumnLayout();
layoutReaderPlayerHost();
};
const stop = () => {
node.releasePointerCapture?.(event.pointerId);
document.body.removeAttribute("data-blr-reader-resizing");
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
state.settings = {
...state.settings,
readerChapterWidthPx: state.readingChapterWidthPx,
readerTranscriptWidthPx: state.readingTranscriptWidthPx,
readerVideoHeightPx: state.readingVideoHeightPx
};
persistReaderSettings();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
});
node.dataset.blrBound = "1";
}
function setReaderPreference(settingKey, nextValue) {
const config = getReaderStepperConfig(settingKey);
if (!config) {
return;
}
const current = config.getCurrent();
if (!config.options.includes(nextValue) || nextValue === current) {
return;
}
updateReaderPreferences(config.buildPayload(nextValue), { persist: true });
}
function renderReaderStepperState(node, settingKey) {
const config = getReaderStepperConfig(settingKey);
if (!node || !config) {
return;
}
const current = config.getCurrent();
node.querySelectorAll("[data-value]").forEach((button) => {
const isActive = button.dataset.value === current;
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-pressed", isActive ? "true" : "false");
});
}
function renderReaderPanels() {
const settingsPanel = byId(ids.readingSettingsPanel);
const settingsBtn = byId(ids.readingSettingsBtn);
settingsPanel.hidden = !state.readingSettingsExpanded;
settingsBtn.classList.toggle("is-active", state.readingSettingsExpanded);
byId(ids.readingAutoScroll).checked = state.readingAutoScroll;
byId(ids.readingTranscriptVisible).checked = state.readingTranscriptVisible;
byId(ids.readingTimestampVisible).checked = state.readingTimestampVisible;
byId(ids.readingTranscriptModeSelect).value = state.readingTranscriptMode;
renderReaderStepperState(byId(ids.readingFontScaleSelect), "readerFontScale");
renderReaderStepperState(byId(ids.readingFontWeightSelect), "readerFontWeight");
renderReaderStepperState(byId(ids.readingLetterSpacingSelect), "readerLetterSpacing");
renderReaderStepperState(byId(ids.readingLineHeightSelect), "readerLineHeight");
}
function renderReadingInfoPanel() {
const summaryNode = byId(ids.readingInfoSummary);
const descriptionNode = byId(ids.readingInfoDescription);
const descriptionBtn = byId(ids.readingDescriptionBtn);
const summaryItems = buildReadingSummaryItems();
const description = String(state.description || "").trim();
summaryNode.innerHTML =
summaryItems.length === 0
? '当前视频信息还未就绪。
'
: summaryItems
.map(
(item) => `
${escapeHtml(item.label)}
${escapeHtml(item.value)}
`
)
.join("");
if (!description) {
descriptionNode.innerHTML = '当前视频没有简介。
';
descriptionNode.classList.remove("is-collapsed");
descriptionBtn.hidden = true;
} else {
descriptionNode.textContent = description;
const fullScrollHeight = descriptionNode.scrollHeight;
descriptionNode.classList.add("is-collapsed");
const clampedClientHeight = descriptionNode.clientHeight;
descriptionNode.classList.toggle("is-collapsed", !state.readingDescriptionExpanded);
const hasOverflow = fullScrollHeight > clampedClientHeight + 2;
if (!hasOverflow) {
descriptionNode.classList.remove("is-collapsed");
descriptionBtn.hidden = true;
return;
}
descriptionBtn.hidden = false;
descriptionBtn.textContent = state.readingDescriptionExpanded ? "收起简介" : "查看更多";
}
}
function buildReadingSummaryItems() {
const items = [];
if (state.title) {
items.push({ label: "标题", value: state.title });
}
if (state.author) {
items.push({ label: "作者", value: state.author });
}
if (state.uploadDate) {
items.push({ label: "日期", value: state.uploadDate });
}
if (Number(state.pageCount) > 1) {
const pageParts = [`P${Number(state.pageIndex) > 0 ? Number(state.pageIndex) : 1}`];
if (state.pageTitle) {
pageParts.push(state.pageTitle);
}
items.push({ label: "分P", value: pageParts.join(" ") });
}
return items;
}
function updateReaderPreferences(next, { persist = true } = {}) {
state.readingTheme = normalizeReaderTheme(next.readerTheme ?? state.readingTheme);
state.readingFontScale = normalizeReaderFontScale(next.readerFontScale ?? state.readingFontScale);
state.readingFontWeight = normalizeReaderFontWeight(next.readerFontWeight ?? state.readingFontWeight);
state.readingLetterSpacing = normalizeReaderLetterSpacing(
next.readerLetterSpacing ?? state.readingLetterSpacing
);
state.readingLineHeight = normalizeReaderLineHeight(next.readerLineHeight ?? state.readingLineHeight);
state.readingContentWidth = normalizeReaderContentWidth(next.readerContentWidth ?? state.readingContentWidth);
state.readingChapterVisible = next.readerChapterVisible !== undefined ? Boolean(next.readerChapterVisible) : state.readingChapterVisible;
state.readingTranscriptVisible = normalizeReaderTranscriptVisible(
next.readerTranscriptVisible ?? state.readingTranscriptVisible
);
state.readingTimestampVisible = normalizeReaderTimestampVisible(
next.readerTimestampVisible ?? state.readingTimestampVisible
);
state.readingTranscriptMode = normalizeReaderTranscriptMode(
next.readerTranscriptMode ?? state.readingTranscriptMode
);
state.settings = {
...state.settings,
readerTheme: state.readingTheme,
readerFontScale: state.readingFontScale,
readerFontWeight: state.readingFontWeight,
readerLetterSpacing: state.readingLetterSpacing,
readerLineHeight: state.readingLineHeight,
readerContentWidth: state.readingContentWidth,
readerChapterVisible: state.readingChapterVisible,
readerTranscriptVisible: state.readingTranscriptVisible,
readerTimestampVisible: state.readingTimestampVisible,
readerTranscriptMode: state.readingTranscriptMode
};
applyReadingViewPresentation();
renderReaderPanels();
if (persist) {
persistReaderSettings();
}
}
function persistReaderSettings() {
sendRuntimeMessage({ type: "save-settings", settings: state.settings }).catch((error) => {
logWarn("[BOC] failed to persist reader settings", error);
});
}
function buildReadingMetaLine() {
const parts = [];
if (state.author) {
parts.push(state.author);
}
if (state.uploadDate) {
parts.push(state.uploadDate);
}
parts.push("bilibili.com");
if (Number(state.pageCount) > 1) {
const pageParts = [`P${Number(state.pageIndex) > 0 ? Number(state.pageIndex) : 1}`];
if (state.pageTitle) {
pageParts.push(state.pageTitle);
}
parts.push(pageParts.join(" "));
}
if (state.selectedSubtitleLang) {
parts.push(`字幕:${state.selectedSubtitleLang}`);
}
return parts.join(" · ");
}
function renderReadingStatus(text) {
byId(ids.readingStatus).textContent = String(text || "");
}
function setReadingViewReady(ready) {
state.readingViewReady = Boolean(ready);
const readingView = document.getElementById(ids.readingView);
if (!readingView) {
return;
}
readingView.setAttribute("data-blr-reader-ready", state.readingViewReady ? "1" : "0");
readingView.setAttribute("aria-busy", state.readingViewReady ? "false" : "true");
}
function isReaderPresentationStable(playerHost = state.readingPlayerHost) {
if (!state.readingViewOpen || !playerHost?.isConnected) {
return false;
}
const rect = playerHost.getBoundingClientRect();
if (!(rect.width > 240) || !(rect.height > 120)) {
return false;
}
if (!state.readingNativePageMode) {
return true;
}
return !hasNativeReaderPlayerLayoutIssue(playerHost);
}
function createReaderDebugSnapshot(label = "manual") {
const pickNodeSnapshot = (selector) => {
const node = document.querySelector(selector);
if (!node) {
return null;
}
const rect = node.getBoundingClientRect();
const style = window.getComputedStyle(node);
return {
selector,
tag: node.tagName,
id: node.id || "",
className: typeof node.className === "string" ? node.className : "",
rect: {
x: Math.round(rect.x),
y: Math.round(rect.y),
w: Math.round(rect.width),
h: Math.round(rect.height)
},
style: {
display: style.display,
position: style.position,
width: style.width,
height: style.height,
maxWidth: style.maxWidth,
maxHeight: style.maxHeight,
top: style.top,
left: style.left,
transform: style.transform,
overflow: style.overflow,
zIndex: style.zIndex
},
attrs: {
readerKeep: node.getAttribute("data-blr-reader-keep"),
readerHidden: node.getAttribute("data-blr-reader-hidden"),
readerReset: node.getAttribute("data-blr-reader-player-reset")
}
};
};
const playerHost = state.readingPlayerHost || findReaderPlayerHost(getRuntimeVideoElement());
const wrapNode = getReaderPlayerWrapNode(playerHost);
const video = state.readingVideoEl || getRuntimeVideoElement();
const hostChain = [];
let current = playerHost;
let depth = 0;
while (current && depth < 8) {
const rect = current.getBoundingClientRect();
const style = window.getComputedStyle(current);
hostChain.push({
tag: current.tagName,
id: current.id || "",
className: typeof current.className === "string" ? current.className : "",
rect: {
x: Math.round(rect.x),
y: Math.round(rect.y),
w: Math.round(rect.width),
h: Math.round(rect.height)
},
style: {
position: style.position,
width: style.width,
height: style.height,
top: style.top,
left: style.left,
transform: style.transform,
overflow: style.overflow,
zIndex: style.zIndex
},
readerReset: current.getAttribute("data-blr-reader-player-reset")
});
current = current.parentElement;
depth += 1;
}
return {
label: String(label || "manual"),
url: cleanVideoUrl(),
readerMode: document.documentElement.getAttribute("data-blr-reader-mode"),
readingActive: document.body.getAttribute("data-blr-reading-active"),
readingViewOpen: state.readingViewOpen,
readingNativePageMode: state.readingNativePageMode,
readingViewReady: state.readingViewReady,
readyStable: isReaderPresentationStable(playerHost),
hasLayoutIssue: hasNativeReaderPlayerLayoutIssue(playerHost),
hasRoot: Boolean(document.getElementById(ids.root)),
hasReadingView: Boolean(document.getElementById(ids.readingView)),
playerHost: playerHost
? {
tag: playerHost.tagName,
id: playerHost.id || "",
className: typeof playerHost.className === "string" ? playerHost.className : ""
}
: null,
wrapNode: wrapNode
? {
tag: wrapNode.tagName,
id: wrapNode.id || "",
className: typeof wrapNode.className === "string" ? wrapNode.className : ""
}
: null,
video: video
? {
currentTime: Number(video.currentTime || 0) || 0,
paused: Boolean(video.paused),
videoWidth: Number(video.videoWidth || 0) || 0,
videoHeight: Number(video.videoHeight || 0) || 0
}
: null,
nodes: [
"#app",
"#playerWrap",
".player-wrap",
"#bilibili-player",
".bpx-player-container",
".bpx-player-video-area",
".bpx-player-primary-area",
"#blr-reading-inline-host",
"#blr-reading-view"
]
.map((selector) => pickNodeSnapshot(selector))
.filter(Boolean),
hostChain
};
}
function bindReaderLayout() {
if (state.readingLayoutBound) {
return;
}
window.addEventListener("resize", layoutReaderPlayerHost);
window.addEventListener("scroll", layoutReaderPlayerHost, { passive: true });
document.addEventListener("fullscreenchange", layoutReaderPlayerHost);
document.addEventListener("webkitfullscreenchange", layoutReaderPlayerHost);
state.readingLayoutBound = true;
}
function unbindReaderLayout() {
if (!state.readingLayoutBound) {
return;
}
window.removeEventListener("resize", layoutReaderPlayerHost);
window.removeEventListener("scroll", layoutReaderPlayerHost);
document.removeEventListener("fullscreenchange", layoutReaderPlayerHost);
document.removeEventListener("webkitfullscreenchange", layoutReaderPlayerHost);
state.readingLayoutBound = false;
}
function layoutReaderPlayerHost() {
if (!state.readingViewOpen || !isReaderMode()) {
return;
}
const readingView = byId(ids.readingView);
applyReaderColumnLayout();
const playerHost = state.readingPlayerHost;
const slot = byId(ids.readingPlayerSlot);
if (!playerHost) {
return;
}
if (state.readingNativePageMode) {
const rect = playerHost.getBoundingClientRect();
if (!(rect.width > 0) || !(rect.height > 0)) {
return;
}
const widthLimit = getReaderMainWidthLimit();
const wrapRect = getReaderPlayerWrapNode(playerHost)?.getBoundingClientRect?.();
const layoutTop = Number.isFinite(wrapRect?.top) ? wrapRect.top : rect.top;
const maxHeight = Math.max(240, window.innerHeight - layoutTop - 148);
let renderedWidth = widthLimit;
let renderedHeight = maxHeight;
if (state.readingVideoHeightPx > 0) {
renderedHeight = Math.min(maxHeight, Math.max(240, state.readingVideoHeightPx));
}
clearNativeReaderFloatingStyles(playerHost);
cleanupReaderPlayerHostNode(playerHost);
[document.documentElement, document.body, readingView].forEach((node) => {
node.style.setProperty("--blr-reader-player-rendered-width", `${Math.round(renderedWidth)}px`);
node.style.setProperty("--blr-reader-player-rendered-height", `${Math.round(renderedHeight)}px`);
});
updateReaderChapterRailPosition();
updateReadingTranscriptTailSpacer();
queueEnsureReaderPlayerControlsRecovered({
reason: "layout-native",
delayMs: 120
});
return;
}
if (!slot) {
return;
}
const rect = slot.getBoundingClientRect();
if (!(rect.width > 0) || !(rect.height > 0)) {
return;
}
const video = state.readingVideoEl;
const aspectRatio =
Number(video?.videoWidth) > 0 && Number(video?.videoHeight) > 0
? Number(video.videoWidth) / Number(video.videoHeight)
: 16 / 9;
const targetHeight = rect.height;
const targetWidth = Math.min(rect.width, targetHeight * aspectRatio);
const left = rect.left + (rect.width - targetWidth) / 2;
[document.documentElement, document.body, readingView].forEach((node) => {
node.style.setProperty("--blr-reader-player-rendered-width", `${Math.round(targetWidth)}px`);
node.style.setProperty("--blr-reader-player-rendered-height", `${Math.round(targetHeight)}px`);
});
playerHost.style.setProperty("position", "fixed", "important");
playerHost.style.setProperty("left", `${Math.round(left)}px`, "important");
playerHost.style.setProperty("top", `${Math.round(rect.top)}px`, "important");
playerHost.style.setProperty("width", `${Math.round(targetWidth)}px`, "important");
playerHost.style.setProperty("height", `${Math.round(targetHeight)}px`, "important");
playerHost.style.setProperty("margin", "0", "important");
playerHost.style.setProperty("z-index", "2147483647", "important");
playerHost.style.setProperty("max-width", "none", "important");
playerHost.style.setProperty("max-height", "none", "important");
updateReaderChapterRailPosition({
left,
bottom: rect.top + targetHeight,
width: targetWidth,
height: targetHeight
});
updateReadingTranscriptTailSpacer();
}
function cleanupReaderPlayerHostNode(playerHost) {
if (!playerHost) {
return;
}
playerHost.classList.remove("blr-reader-player-host");
playerHost.style.removeProperty("position");
playerHost.style.removeProperty("inset");
playerHost.style.removeProperty("left");
playerHost.style.removeProperty("top");
playerHost.style.removeProperty("right");
playerHost.style.removeProperty("bottom");
playerHost.style.removeProperty("transform");
playerHost.style.removeProperty("width");
playerHost.style.removeProperty("height");
playerHost.style.removeProperty("margin");
playerHost.style.removeProperty("z-index");
playerHost.style.removeProperty("max-width");
playerHost.style.removeProperty("max-height");
}
function cleanupReaderPlayerHost() {
restoreReaderPlayerContainer();
unbindReaderPlayerControlsHover();
unbindReaderHeaderActionsHover();
if (state.readingControlsRecoveryTimer) {
window.clearTimeout(state.readingControlsRecoveryTimer);
state.readingControlsRecoveryTimer = 0;
}
state.readingControlsRecoveryInFlight = false;
const readingView = byId(ids.readingView);
[document.documentElement, document.body, readingView].filter(Boolean).forEach((node) => {
node.style.removeProperty("--blr-reader-player-rendered-width");
node.style.removeProperty("--blr-reader-player-rendered-height");
});
readingView?.style.removeProperty("--blr-reader-player-left");
readingView?.style.removeProperty("--blr-reader-player-bottom");
readingView?.style.removeProperty("--blr-reader-player-width");
const playerHost = state.readingPlayerHost;
if (!playerHost) {
return;
}
setReaderPlayerControlsVisible(false, playerHost);
cleanupReaderPlayerHostNode(playerHost);
state.readingPlayerHost = null;
}
function startReadingViewSync() {
if (state.readingSyncTimer) {
window.clearInterval(state.readingSyncTimer);
}
state.readingSyncTimer = window.setInterval(() => {
syncReadingViewPlayback();
}, 250);
}
function stopReadingViewSync() {
if (state.readingSyncTimer) {
window.clearInterval(state.readingSyncTimer);
state.readingSyncTimer = 0;
}
if (state.readingMiniDismissTimer) {
window.clearTimeout(state.readingMiniDismissTimer);
state.readingMiniDismissTimer = 0;
}
if (state.readingControlsHideTimer) {
window.clearTimeout(state.readingControlsHideTimer);
state.readingControlsHideTimer = 0;
}
if (state.readingControlsRecoveryTimer) {
window.clearTimeout(state.readingControlsRecoveryTimer);
state.readingControlsRecoveryTimer = 0;
}
state.readingControlsRecoveryInFlight = false;
if (state.readingPlayerMountTimer) {
window.clearTimeout(state.readingPlayerMountTimer);
state.readingPlayerMountTimer = 0;
}
if (state.readingPlayerRetryTimer) {
window.clearTimeout(state.readingPlayerRetryTimer);
state.readingPlayerRetryTimer = 0;
}
stopReaderPlayerObserver();
unbindReaderPlayerControlsHover();
if (state.readingVideoEl && state.readingVideoEl.__blrReadingSyncHandler) {
const video = state.readingVideoEl;
video.removeEventListener("timeupdate", video.__blrReadingSyncHandler);
video.removeEventListener("seeked", video.__blrReadingSyncHandler);
video.removeEventListener("loadedmetadata", video.__blrReadingSyncHandler);
delete video.__blrReadingSyncHandler;
}
state.readingVideoEventsBound = false;
}
function startReaderPlayerObserver() {
if (!isReaderMode() || state.readingPlayerObserver || !document.body) {
return;
}
const observer = new MutationObserver(() => {
if (!state.readingViewOpen) {
return;
}
const nextVideo = getRuntimeVideoElement();
const nextHost = findReaderPlayerHost(nextVideo);
if (nextVideo && nextHost && (nextVideo !== state.readingVideoEl || nextHost !== state.readingPlayerHost)) {
queueEnsureReaderPlayerMounted();
}
if (document.querySelector(".bpx-player-mini-close, .bpx-player-mini-warp")) {
scheduleReaderMiniPlayerDismiss();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
state.readingPlayerObserver = observer;
}
function stopReaderPlayerObserver() {
if (state.readingPlayerObserver) {
state.readingPlayerObserver.disconnect();
state.readingPlayerObserver = null;
}
}
function bindReadingViewVideo(video = getRuntimeVideoElement()) {
if (!video) {
if (state.readingVideoEl && state.readingVideoEl.__blrReadingSyncHandler) {
const prev = state.readingVideoEl;
prev.removeEventListener("timeupdate", prev.__blrReadingSyncHandler);
prev.removeEventListener("seeked", prev.__blrReadingSyncHandler);
prev.removeEventListener("loadedmetadata", prev.__blrReadingSyncHandler);
delete prev.__blrReadingSyncHandler;
}
state.readingVideoEl = null;
state.readingVideoEventsBound = false;
return null;
}
if (state.readingVideoEl === video && state.readingVideoEventsBound) {
return video;
}
if (state.readingVideoEl && state.readingVideoEl.__blrReadingSyncHandler) {
const prev = state.readingVideoEl;
prev.removeEventListener("timeupdate", prev.__blrReadingSyncHandler);
prev.removeEventListener("seeked", prev.__blrReadingSyncHandler);
prev.removeEventListener("loadedmetadata", prev.__blrReadingSyncHandler);
}
const syncHandler = (event) => {
if (state.readingViewOpen) {
if (event?.type === "loadedmetadata") {
layoutReaderPlayerHost();
}
if (event?.type === "seeked") {
state.readingNextScrollBehavior = "auto";
queueEnsureReaderPlayerControlsRecovered({
reason: "seeked",
delayMs: 140,
minIntervalMs: 320
});
}
const latestHost = findReaderPlayerHost(video);
if (latestHost && latestHost !== state.readingPlayerHost) {
queueEnsureReaderPlayerMounted();
}
syncReadingViewPlayback();
}
};
video.addEventListener("timeupdate", syncHandler);
video.addEventListener("seeked", syncHandler);
video.addEventListener("loadedmetadata", syncHandler);
video.__blrReadingSyncHandler = syncHandler;
state.readingVideoEl = video;
state.readingPlayerHost = findReaderPlayerHost(video) || state.readingPlayerHost;
state.readingVideoEventsBound = true;
return video;
}
function getRuntimeVideoElement() {
if (state.readingVideoEl?.isConnected) {
const currentHost = findReaderPlayerHost(state.readingVideoEl);
const currentRect = state.readingVideoEl.getBoundingClientRect();
if (
currentHost?.isConnected &&
currentRect.width > 120 &&
currentRect.height > 68 &&
!isIgnoredReaderVideoCandidate(state.readingVideoEl)
) {
return state.readingVideoEl;
}
}
const candidates = Array.from(document.querySelectorAll("video")).filter(
(item) => item.isConnected && !isIgnoredReaderVideoCandidate(item)
);
if (candidates.length === 0) {
return null;
}
const visible = candidates
.map((item) => {
const rect = item.getBoundingClientRect();
const host = findReaderPlayerHost(item);
const inPlayer = Boolean(
host &&
(host.matches?.("#bilibili-player, .bpx-player-container, .bpx-player-video-area") ||
host.querySelector?.(".bpx-player-video-area"))
);
const area = Math.max(0, rect.width) * Math.max(0, rect.height);
const score =
area +
(inPlayer ? 1000000 : 0) +
(!item.paused ? 20000 : 0) +
Number(item.readyState || 0) * 2000 +
(item.currentSrc ? 10000 : 0) +
(item === state.readingVideoEl ? 500 : 0);
return { item, rect, score };
})
.filter(({ rect }) => rect.width > 240 && rect.height > 120)
.sort((a, b) => b.score - a.score)[0];
return visible?.item || candidates[0] || null;
}
function isIgnoredReaderVideoCandidate(video) {
if (!video) {
return true;
}
const host = findReaderPlayerHost(video);
const blockedSelector = [
"[data-blr-reader-hidden='1']",
".bpx-player-mini-warp",
".bpx-player-mini-close",
".bpx-player-ending-panel",
".bpx-player-ending-related",
"[class*='mini-player']",
"[class*='picture-in-picture']",
"[class*='adcard']",
".ad-report",
"[class*='ad-report']",
".video-page-card-small",
".video-page-special-card-small",
".feed-card",
".bili-video-card"
].join(", ");
return Boolean(video.closest(blockedSelector) || host?.closest?.(blockedSelector));
}
function applyReaderPageFocus() {
clearReaderPageFocus();
const root = byId(ids.root);
const video = getRuntimeVideoElement();
const playerHost = findReaderPlayerHost(video);
const titleNode = findReaderTitleContainer();
const keepRoots = [root, playerHost, titleNode].filter(Boolean);
keepRoots.forEach((node) => {
markReaderKeepSubtree(node);
markReaderKeepPath(node);
});
const keepNodes = Array.from(document.querySelectorAll("[data-blr-reader-keep='1']"));
keepNodes.forEach((parent) => {
Array.from(parent.children || []).forEach((child) => {
if (child.id === ids.root) {
return;
}
if (!child.hasAttribute("data-blr-reader-keep")) {
child.setAttribute("data-blr-reader-hidden", "1");
}
});
});
pruneReaderNonKeepBranches(document.body);
hideReaderNoiseNodes(keepRoots);
}
function clearReaderPageFocus() {
document.querySelectorAll("[data-blr-reader-keep]").forEach((node) => {
node.removeAttribute("data-blr-reader-keep");
});
document.querySelectorAll("[data-blr-reader-hidden]").forEach((node) => {
node.removeAttribute("data-blr-reader-hidden");
});
}
function moveReadingMainInline() {
if (!isReaderMode()) {
return;
}
const readingMain = document.querySelector(".blr-reading-main");
if (!readingMain) {
return;
}
if (!state.readingMainOriginalParent) {
state.readingMainOriginalParent = readingMain.parentElement;
state.readingMainOriginalNextSibling = readingMain.nextSibling;
}
const playerWrap =
document.getElementById("playerWrap") ||
state.readingPlayerHost?.closest?.("#playerWrap") ||
state.readingPlayerHost;
if (!playerWrap) {
return;
}
let inlineHost = document.getElementById("blr-reading-inline-host");
if (!inlineHost) {
inlineHost = document.createElement("div");
inlineHost.id = "blr-reading-inline-host";
}
// Keep the transcript outside Bilibili's transformed layout containers.
// A transformed ancestor changes the containing block of position: fixed and
// makes the right column overlap the player instead of hugging the viewport.
if (inlineHost.parentElement !== document.body) {
document.body.appendChild(inlineHost);
}
let transcriptHeading = document.getElementById("blr-reading-transcript-heading");
if (!transcriptHeading) {
transcriptHeading = document.createElement("div");
transcriptHeading.id = "blr-reading-transcript-heading";
transcriptHeading.className = "blr-reading-transcript-heading";
transcriptHeading.setAttribute("role", "heading");
transcriptHeading.setAttribute("aria-level", "2");
transcriptHeading.textContent = "字幕";
}
if (transcriptHeading.parentElement !== inlineHost || inlineHost.firstElementChild !== transcriptHeading) {
inlineHost.prepend(transcriptHeading);
}
if (!inlineHost.dataset.blrScrollBound) {
const handleInlineHostManualScroll = () => {
if (Date.now() <= state.readingProgrammaticScrollUntil) {
return;
}
noteManualReaderInteraction();
};
inlineHost.addEventListener("scroll", handleInlineHostManualScroll);
inlineHost.addEventListener("wheel", handleInlineHostManualScroll, { passive: true });
inlineHost.dataset.blrScrollBound = "1";
}
if (readingMain.parentElement !== inlineHost) {
inlineHost.appendChild(readingMain);
}
const leftContainer = document.querySelector(".left-container");
const bgColor = leftContainer ? getComputedStyle(leftContainer).backgroundColor : "";
if (state.readingTranscriptVisible) {
inlineHost.style.border = "";
inlineHost.style.background = "";
inlineHost.style.marginTop = "";
inlineHost.style.boxShadow = "";
inlineHost.style.borderRadius = "";
} else {
inlineHost.style.border = "none";
inlineHost.style.background = bgColor;
inlineHost.style.marginTop = "0";
inlineHost.style.boxShadow = "none";
inlineHost.style.borderRadius = "0";
}
updateReadingTranscriptTailSpacer();
}
function restoreReadingMainInline() {
const readingMain = document.querySelector(".blr-reading-main");
const inlineHost = document.getElementById("blr-reading-inline-host");
if (readingMain && state.readingMainOriginalParent) {
if (state.readingMainOriginalNextSibling?.parentNode === state.readingMainOriginalParent) {
state.readingMainOriginalParent.insertBefore(readingMain, state.readingMainOriginalNextSibling);
} else {
state.readingMainOriginalParent.appendChild(readingMain);
}
}
inlineHost?.remove();
state.readingMainOriginalParent = null;
state.readingMainOriginalNextSibling = null;
}
function pruneReaderNonKeepBranches(node) {
if (!node?.children?.length) {
return;
}
Array.from(node.children).forEach((child) => {
if (child.id === ids.root) {
return;
}
const childHasKeep = child.hasAttribute("data-blr-reader-keep");
const childContainsKeep = Boolean(child.querySelector?.("[data-blr-reader-keep='1']"));
if (!childHasKeep && !childContainsKeep) {
child.setAttribute("data-blr-reader-hidden", "1");
return;
}
pruneReaderNonKeepBranches(child);
});
}
function hideReaderNoiseNodes(keepRoots = []) {
const keepSet = new Set(keepRoots.filter(Boolean));
const selectors = [
".strip-ad-inner",
".inside-wrp",
".inside-bg",
".hinter-msg",
".slide",
".cover.b-img",
".cover.b-img.sleepy",
".b-img.clickable",
"[class*='activity']",
"[class*='adcard']"
];
document.querySelectorAll(selectors.join(",")).forEach((node) => {
if (Array.from(keepSet).some((keepNode) => keepNode === node || node.contains(keepNode))) {
return;
}
if (
node.closest(
"#bilibili-player, .bpx-player-container, .bpx-player-video-area, .bpx-player-primary-area, #blr-root, h1.video-title, .video-info-detail, .video-info-meta, .video-data"
)
) {
return;
}
node.setAttribute("data-blr-reader-hidden", "1");
const card = node.closest("article, li, .card-box, .video-page-card-small, .video-page-special-card-small, .feed-card, .bili-video-card");
if (card && !card.closest("#bilibili-player, .bpx-player-container, .bpx-player-video-area, .bpx-player-primary-area, #blr-root")) {
card.setAttribute("data-blr-reader-hidden", "1");
}
});
}
function markReaderKeepSubtree(node) {
if (!node) {
return;
}
node.setAttribute("data-blr-reader-keep", "1");
node.querySelectorAll("*").forEach((child) => {
child.setAttribute("data-blr-reader-keep", "1");
});
}
function markReaderKeepPath(node) {
let current = node;
while (current && current !== document.body) {
current.setAttribute("data-blr-reader-keep", "1");
current = current.parentElement;
}
document.body.setAttribute("data-blr-reader-keep", "1");
}
function findReaderTitleContainer() {
const title =
document.querySelector("h1.video-title") ||
document.querySelector("h1") ||
document.querySelector("[data-title]");
if (!title) {
return null;
}
return title;
}
function findReaderMetaContainer(titleNode = findReaderTitleContainer()) {
const title = titleNode?.matches?.("h1, [data-title]") ? titleNode : titleNode?.querySelector?.("h1, [data-title]");
if (!title) {
return null;
}
const candidates = [
title.nextElementSibling,
title.parentElement?.nextElementSibling,
title.parentElement,
title.parentElement?.parentElement,
...(Array.from(title.parentElement?.parentElement?.children || []).slice(0, 6))
].filter(Boolean);
for (const node of candidates) {
if (node.matches?.(".video-data, .video-info-detail, .video-info-meta")) {
return node;
}
if (node.querySelector?.(".view-text")) {
return node;
}
}
return null;
}
function findReaderContentHost(playerHost = state.readingPlayerHost, titleNode = findReaderTitleContainer()) {
if (!playerHost && !titleNode) {
return null;
}
let current = titleNode || playerHost;
while (current && current !== document.body) {
const containsPlayer = playerHost ? current.contains(playerHost) : true;
const containsTitle = titleNode ? current.contains(titleNode) : true;
if (containsPlayer && containsTitle) {
return current;
}
current = current.parentElement;
}
return playerHost?.parentElement || titleNode?.parentElement || null;
}
function moveRootToReaderContentHost() {
return;
}
function restoreRootMount() {
return;
}
function dismissReaderMiniPlayer(playerHost = state.readingPlayerHost) {
const explicitClose = Array.from(document.querySelectorAll(".bpx-player-mini-close")).find(isVisibleReaderControl);
if (explicitClose) {
explicitClose.click();
return true;
}
if (!playerHost) {
return false;
}
const computed = window.getComputedStyle(playerHost);
const fixedLike = computed.position === "fixed" || /mini|picture|float|fixed-player/i.test(playerHost.className || "");
if (!fixedLike) {
return false;
}
const roots = Array.from(
new Set([
playerHost,
playerHost.parentElement,
playerHost.closest("#playerWrap"),
playerHost.closest("#bilibili-player")
].filter(Boolean))
);
const selectors = [
".bpx-player-mini-close",
"[class*='mini'][class*='close']",
"[class*='close']",
"button[aria-label*='关闭']",
"button[title*='关闭']",
"[role='button'][aria-label*='关闭']",
"[role='button'][title*='关闭']"
];
for (const root of roots) {
for (const selector of selectors) {
const candidates = Array.from(root.querySelectorAll(selector)).filter(isVisibleReaderControl);
const button = candidates.sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
return rectA.width * rectA.height - rectB.width * rectB.height;
})[0];
if (button) {
button.click();
return true;
}
}
}
const playerRect = playerHost.getBoundingClientRect();
for (const root of roots) {
const fallback = Array.from(root.querySelectorAll("button, [role='button'], [tabindex], div, span"))
.filter((node) => {
if (!isVisibleReaderControl(node)) {
return false;
}
const rect = node.getBoundingClientRect();
const style = window.getComputedStyle(node);
const nearTopRight =
rect.width <= 48 &&
rect.height <= 48 &&
rect.left >= playerRect.right - 96 &&
rect.top <= playerRect.top + 96;
return nearTopRight && (style.cursor === "pointer" || node.hasAttribute("role") || node.hasAttribute("tabindex"));
})
.sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
return rectA.top + (playerRect.right - rectA.right) - (rectB.top + (playerRect.right - rectB.right));
})[0];
if (fallback) {
fallback.click();
return true;
}
}
return false;
}
function scheduleReaderMiniPlayerDismiss(maxAttempts = 12, delayMs = 180) {
if (!state.readingViewOpen) {
return;
}
if (state.readingMiniDismissTimer) {
window.clearTimeout(state.readingMiniDismissTimer);
state.readingMiniDismissTimer = 0;
}
let attempts = 0;
const run = () => {
if (!state.readingViewOpen) {
state.readingMiniDismissTimer = 0;
return;
}
const closed = dismissReaderMiniPlayer();
const host = findReaderPlayerHost(getRuntimeVideoElement());
if (host) {
state.readingPlayerHost = host;
normalizeReaderPlayerContainer(host);
layoutReaderPlayerHost();
}
attempts += 1;
const miniExists = Boolean(document.querySelector(".bpx-player-mini-close, .bpx-player-mini-warp"));
const hostFixed = Boolean(host && window.getComputedStyle(host).position === "fixed");
if (attempts < maxAttempts && (miniExists || hostFixed || closed)) {
state.readingMiniDismissTimer = window.setTimeout(run, delayMs);
return;
}
state.readingMiniDismissTimer = 0;
};
state.readingMiniDismissTimer = window.setTimeout(run, 40);
}
function getReaderControlsRoot(playerHost = state.readingPlayerHost) {
return (
playerHost?.closest?.("#playerWrap") ||
playerHost?.closest?.("#bilibili-player") ||
playerHost ||
document.getElementById("playerWrap") ||
document.getElementById("bilibili-player")
);
}
function getReaderPlayerControlsState(playerHost = state.readingPlayerHost) {
const controlRoot = getReaderControlsRoot(playerHost);
const nodes = [".bpx-player-control-wrap", ".bpx-player-control-mask", ".bpx-player-control-entity"].map(
(selector) => {
const node = controlRoot?.querySelector(selector) || null;
return {
selector,
exists: Boolean(node),
visible: isVisibleReaderControl(node)
};
}
);
return {
controlRootFound: Boolean(controlRoot),
hostHasNoCursor: Boolean(playerHost?.classList.contains("bpx-state-no-cursor")),
anyPresent: nodes.some((item) => item.exists),
anyHidden: nodes.some((item) => item.exists && !item.visible),
nodes
};
}
function hasReaderPlayerControlsIssue(playerHost = state.readingPlayerHost) {
if (!state.readingNativePageMode || !playerHost || isWatchlaterPage()) {
return false;
}
const snapshot = getReaderPlayerControlsState(playerHost);
return snapshot.hostHasNoCursor || (snapshot.anyPresent && snapshot.anyHidden);
}
function queueEnsureReaderPlayerControlsRecovered({
reason = "unknown",
delayMs = 120,
minIntervalMs = 480
} = {}) {
if (!state.readingViewOpen || !state.readingNativePageMode || isWatchlaterPage()) {
return;
}
const playerHost = state.readingPlayerHost;
if (!playerHost?.isConnected || state.readingControlsRecoveryInFlight) {
return;
}
const now = Date.now();
if (state.readingControlsRecoveryTimer) {
return;
}
if (now - state.readingControlsLastRecoverAt < minIntervalMs) {
return;
}
state.readingControlsRecoveryTimer = window.setTimeout(() => {
state.readingControlsRecoveryTimer = 0;
if (!state.readingViewOpen || !state.readingNativePageMode || isWatchlaterPage()) {
return;
}
const activeHost = state.readingPlayerHost;
if (!activeHost?.isConnected || !hasReaderPlayerControlsIssue(activeHost)) {
return;
}
state.readingControlsRecoveryInFlight = true;
state.readingControlsLastRecoverAt = Date.now();
ensureReaderPlayerControlsRecovered(activeHost, {
reason,
retryDelayMs: 120
})
.catch((error) => {
logWarn("[BOC] queued reader controls recovery failed", { reason, error });
})
.finally(() => {
state.readingControlsRecoveryInFlight = false;
});
}, delayMs);
}
function setReaderPlayerControlsVisible(visible, playerHost = state.readingPlayerHost) {
if (!state.readingNativePageMode || !playerHost) {
return;
}
const controlRoot = getReaderControlsRoot(playerHost);
if (!controlRoot) {
return;
}
const displayMap = new Map([
[".bpx-player-control-wrap", "block"],
[".bpx-player-control-mask", "block"],
[".bpx-player-control-entity", "block"]
]);
displayMap.forEach((displayValue, selector) => {
const node = controlRoot.querySelector(selector);
if (!node) {
return;
}
if (visible) {
node.style.setProperty("display", displayValue, "important");
node.setAttribute("data-blr-reader-controls-forced", "1");
return;
}
if (node.getAttribute("data-blr-reader-controls-forced") === "1") {
node.style.removeProperty("display");
node.removeAttribute("data-blr-reader-controls-forced");
}
});
if (visible) {
if (playerHost.classList.contains("bpx-state-no-cursor")) {
playerHost.classList.remove("bpx-state-no-cursor");
playerHost.setAttribute("data-blr-reader-no-cursor-cleared", "1");
}
return;
}
if (playerHost.getAttribute("data-blr-reader-no-cursor-cleared") === "1") {
playerHost.classList.add("bpx-state-no-cursor");
playerHost.removeAttribute("data-blr-reader-no-cursor-cleared");
}
}
async function ensureReaderPlayerControlsRecovered(
playerHost = state.readingPlayerHost,
{ reason = "unknown", retryDelayMs = 90 } = {}
) {
if (!state.readingNativePageMode || !playerHost || isWatchlaterPage()) {
return false;
}
const before = getReaderPlayerControlsState(playerHost);
logInfo("[BOC] reader controls check", {
reason,
hostClassName: typeof playerHost.className === "string" ? playerHost.className : "",
hostHasNoCursor: before.hostHasNoCursor,
controlRootFound: before.controlRootFound,
controls: before.nodes
});
if (!hasReaderPlayerControlsIssue(playerHost)) {
return false;
}
logInfo("[BOC] recovering normal reader controls", {
reason,
hostClassName: typeof playerHost.className === "string" ? playerHost.className : ""
});
setReaderPlayerControlsVisible(true, playerHost);
layoutReaderPlayerHost();
let after = getReaderPlayerControlsState(playerHost);
logInfo("[BOC] reader controls after recovery", {
reason,
hostClassName: typeof playerHost.className === "string" ? playerHost.className : "",
hostHasNoCursor: after.hostHasNoCursor,
controls: after.nodes,
retried: false
});
if (!hasReaderPlayerControlsIssue(playerHost)) {
return true;
}
await sleep(retryDelayMs);
logInfo("[BOC] retrying normal reader controls recovery", {
reason,
hostClassName: typeof playerHost.className === "string" ? playerHost.className : ""
});
setReaderPlayerControlsVisible(true, playerHost);
layoutReaderPlayerHost();
after = getReaderPlayerControlsState(playerHost);
logInfo("[BOC] reader controls after retry", {
reason,
hostClassName: typeof playerHost.className === "string" ? playerHost.className : "",
hostHasNoCursor: after.hostHasNoCursor,
controls: after.nodes,
retried: true
});
return !hasReaderPlayerControlsIssue(playerHost);
}
function scheduleReaderPlayerControlsHide(playerHost = state.readingControlsHoverHost || state.readingPlayerHost) {
if (state.readingControlsHideTimer) {
window.clearTimeout(state.readingControlsHideTimer);
}
state.readingControlsHideTimer = window.setTimeout(() => {
state.readingControlsHideTimer = 0;
if (!state.readingViewOpen) {
return;
}
setReaderPlayerControlsVisible(false, playerHost);
}, 1200);
}
function bindReaderPlayerControlsHover(playerHost = state.readingPlayerHost) {
if (!state.readingNativePageMode || !isWatchlaterPage() || !playerHost) {
return;
}
if (state.readingControlsHoverHost && state.readingControlsHoverHost !== playerHost) {
unbindReaderPlayerControlsHover();
}
if (playerHost.__blrReaderControlsHoverBound) {
state.readingControlsHoverHost = playerHost;
return;
}
const showControls = () => {
if (!state.readingViewOpen) {
return;
}
setReaderPlayerControlsVisible(true, playerHost);
scheduleReaderPlayerControlsHide(playerHost);
};
const hideControls = () => {
if (state.readingControlsHideTimer) {
window.clearTimeout(state.readingControlsHideTimer);
state.readingControlsHideTimer = 0;
}
setReaderPlayerControlsVisible(false, playerHost);
};
playerHost.addEventListener("mouseenter", showControls, true);
playerHost.addEventListener("mousemove", showControls, true);
playerHost.addEventListener("mouseleave", hideControls, true);
playerHost.__blrReaderControlsHoverBound = { showControls, hideControls };
state.readingControlsHoverHost = playerHost;
}
function unbindReaderPlayerControlsHover() {
const playerHost = state.readingControlsHoverHost;
if (state.readingControlsHideTimer) {
window.clearTimeout(state.readingControlsHideTimer);
state.readingControlsHideTimer = 0;
}
if (!playerHost?.__blrReaderControlsHoverBound) {
state.readingControlsHoverHost = null;
return;
}
const { showControls, hideControls } = playerHost.__blrReaderControlsHoverBound;
playerHost.removeEventListener("mouseenter", showControls, true);
playerHost.removeEventListener("mousemove", showControls, true);
playerHost.removeEventListener("mouseleave", hideControls, true);
delete playerHost.__blrReaderControlsHoverBound;
setReaderPlayerControlsVisible(false, playerHost);
state.readingControlsHoverHost = null;
}
function setReaderHeaderActionsVisible(visible) {
const actions = document.querySelector(".blr-reading-actions");
if (!actions) {
return;
}
if (visible) {
actions.removeAttribute("data-blr-icon-hidden");
return;
}
actions.setAttribute("data-blr-icon-hidden", "1");
}
function scheduleReaderHeaderActionsHide(delayMs = 10000) {
if (state.readingHeaderHideTimer) {
window.clearTimeout(state.readingHeaderHideTimer);
state.readingHeaderHideTimer = 0;
}
state.readingHeaderHideTimer = window.setTimeout(() => {
state.readingHeaderHideTimer = 0;
if (!state.readingViewOpen) {
return;
}
setReaderHeaderActionsVisible(false);
}, delayMs);
}
function bindReaderHeaderActionsHover() {
if (!state.readingViewOpen) {
return;
}
const header = document.querySelector(".blr-reading-header");
if (!header || header.__blrReaderHeaderHoverBound) {
state.readingHeaderHoverHost = header || null;
return;
}
const showActions = () => {
if (!state.readingViewOpen) {
return;
}
if (state.readingHeaderHideTimer) {
window.clearTimeout(state.readingHeaderHideTimer);
state.readingHeaderHideTimer = 0;
}
setReaderHeaderActionsVisible(true);
};
const hideActionsLater = () => {
if (!state.readingViewOpen) {
return;
}
scheduleReaderHeaderActionsHide();
};
header.addEventListener("mouseenter", showActions, true);
header.addEventListener("mouseleave", hideActionsLater, true);
header.__blrReaderHeaderHoverBound = { showActions, hideActionsLater };
state.readingHeaderHoverHost = header;
setReaderHeaderActionsVisible(true);
scheduleReaderHeaderActionsHide();
}
function unbindReaderHeaderActionsHover() {
const header = state.readingHeaderHoverHost;
if (state.readingHeaderHideTimer) {
window.clearTimeout(state.readingHeaderHideTimer);
state.readingHeaderHideTimer = 0;
}
if (!header?.__blrReaderHeaderHoverBound) {
state.readingHeaderHoverHost = null;
return;
}
const { showActions, hideActionsLater } = header.__blrReaderHeaderHoverBound;
header.removeEventListener("mouseenter", showActions, true);
header.removeEventListener("mouseleave", hideActionsLater, true);
delete header.__blrReaderHeaderHoverBound;
state.readingHeaderHoverHost = null;
setReaderHeaderActionsVisible(true);
}
function startPlayerAiQuickActionObserver() {
if (state.playerAiQuickActionObserver || !document.body) {
return;
}
const observer = new MutationObserver(() => {
schedulePlayerAiQuickActionSync();
});
observer.observe(document.body, {
childList: true,
subtree: true
});
state.playerAiQuickActionObserver = observer;
}
function bindPlayerAiQuickActionLayoutEvents() {
if (state.playerAiQuickActionLayoutBound) {
return;
}
const schedule = () => schedulePlayerAiQuickActionSync(80);
window.addEventListener("resize", schedule, { passive: true });
window.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("pageshow", schedule, { passive: true });
document.addEventListener("fullscreenchange", schedule);
document.addEventListener("webkitfullscreenchange", schedule);
window.visualViewport?.addEventListener?.("resize", schedule, { passive: true });
state.playerAiQuickActionLayoutBound = true;
}
function schedulePlayerAiQuickActionSync(delayMs = 120) {
if (state.playerAiQuickActionSyncTimer) {
window.clearTimeout(state.playerAiQuickActionSyncTimer);
}
state.playerAiQuickActionSyncTimer = window.setTimeout(() => {
state.playerAiQuickActionSyncTimer = 0;
syncPlayerAiQuickActionButton();
}, delayMs);
}
function syncPlayerAiQuickActionButton() {
const existing = document.getElementById("blr-player-ai-quick-action");
const existingWrap = existing?.closest(".blr-player-ai-wrap");
if (!state.settings?.enablePlayerAiQuickAction || state.readingViewOpen || isReaderMode()) {
removePlayerAiQuickActionButton();
return;
}
if (!hasPlayerSubtitleControl()) {
removePlayerAiQuickActionButton();
return;
}
const playerHost = findPlayerAiQuickActionHost();
if (!playerHost) {
if (!existingWrap?.isConnected) {
existingWrap?.remove();
existing?.remove();
} else {
schedulePlayerAiQuickActionSync(260);
}
return;
}
let button = existing;
let wrap = existingWrap instanceof HTMLElement ? existingWrap : null;
if (!wrap) {
wrap = document.createElement("div");
wrap.className = "blr-player-ai-wrap";
wrap.setAttribute("data-blr-extension-node", "ai-quick-action");
}
if (!button) {
button = document.createElement("button");
button.id = "blr-player-ai-quick-action";
button.type = "button";
button.className = "blr-player-ai-quick-action";
button.title = "用 AI 分析这期视频";
button.setAttribute("aria-label", "用 AI 分析这期视频");
button.innerHTML = buildPlayerAiQuickActionIconSvg();
button.addEventListener("click", handlePlayerAiQuickActionClick, true);
}
if (button.parentElement !== wrap) {
wrap.replaceChildren(button);
}
if (wrap.parentElement !== playerHost) {
playerHost.appendChild(wrap);
}
bindPlayerAiQuickActionCursorSync(wrap);
syncPlayerAiQuickActionVisuals(button);
}
function removePlayerAiQuickActionButton() {
if (state.playerAiQuickActionRevealTimer) {
window.clearTimeout(state.playerAiQuickActionRevealTimer);
state.playerAiQuickActionRevealTimer = 0;
}
if (state.playerAiQuickActionHideTimer) {
window.clearTimeout(state.playerAiQuickActionHideTimer);
state.playerAiQuickActionHideTimer = 0;
}
if (state.playerAiQuickActionCursorHideTimer) {
window.clearTimeout(state.playerAiQuickActionCursorHideTimer);
state.playerAiQuickActionCursorHideTimer = 0;
}
document.getElementById("blr-player-ai-quick-action")?.closest(".blr-player-ai-wrap")?.remove();
}
function bindPlayerAiQuickActionCursorSync(wrap) {
if (!(wrap instanceof HTMLElement)) {
return;
}
const host = wrap.parentElement instanceof HTMLElement ? wrap.parentElement : null;
if (!host || wrap.__blrPlayerAiCursorHost === host) {
return;
}
const hideForIdle = () => {
state.playerAiQuickActionCursorHideTimer = 0;
wrap.classList.remove("is-active");
};
const showForCursorActivity = () => {
if (!wrap.isConnected || state.readingViewOpen || isReaderMode()) {
wrap.classList.remove("is-active");
return;
}
wrap.classList.add("is-active");
if (state.playerAiQuickActionCursorHideTimer) {
window.clearTimeout(state.playerAiQuickActionCursorHideTimer);
}
state.playerAiQuickActionCursorHideTimer = window.setTimeout(hideForIdle, 1900);
};
const hideImmediately = () => {
if (state.playerAiQuickActionCursorHideTimer) {
window.clearTimeout(state.playerAiQuickActionCursorHideTimer);
state.playerAiQuickActionCursorHideTimer = 0;
}
wrap.classList.remove("is-active");
};
host.addEventListener("mousemove", showForCursorActivity, { passive: true });
host.addEventListener("mouseenter", showForCursorActivity, { passive: true });
host.addEventListener("mouseleave", hideImmediately, { passive: true });
host.addEventListener("pointermove", showForCursorActivity, { passive: true });
wrap.__blrPlayerAiCursorHost = host;
}
function hasPlayerSubtitleControl() {
return Boolean(findPlayerSubtitleControlNode());
}
function findPlayerSubtitleControlNode() {
const controlRoots = Array.from(
document.querySelectorAll(
"#bilibili-player .bpx-player-control-wrap, #playerWrap .bpx-player-control-wrap, .bpx-player-container .bpx-player-control-wrap, #bilibili-player, #playerWrap, .bpx-player-container"
)
);
for (const root of controlRoots) {
const candidates = Array.from(
root.querySelectorAll(
"[aria-label*='字幕'], [title*='字幕'], [data-text*='字幕'], [class*='subtitle'], [class*='caption'], button, [role='button']"
)
);
const matched = candidates.find((node) => isPlayerSubtitleControlNode(node));
if (matched) {
return matched;
}
}
return null;
}
function isPlayerSubtitleControlNode(node) {
if (!(node instanceof Element)) {
return false;
}
const text = [
node.getAttribute("aria-label"),
node.getAttribute("title"),
node.getAttribute("data-text"),
node.textContent,
typeof node.className === "string" ? node.className : ""
]
.filter((item) => typeof item === "string" && item.trim())
.join(" ");
return /字幕|subtitle/i.test(text);
}
function findPlayerAiQuickActionHost() {
const candidates = [
document.querySelector(".bpx-player-container"),
document.querySelector(".bpx-player-video-area"),
document.getElementById("bilibili-player"),
document.getElementById("playerWrap")
];
return candidates.find((node) => node instanceof HTMLElement && isVisibleReaderControl(node)) || null;
}
function buildPlayerAiQuickActionIconSvg() {
const variants = {
badge: `
`,
sparkles: `
`,
nodes: `
`,
chip: `
`
};
return variants[PLAYER_AI_ICON_VARIANT] || variants.badge;
}
function syncPlayerAiQuickActionVisuals(button) {
if (!(button instanceof HTMLElement)) {
return;
}
const wrap = button.parentElement instanceof HTMLElement ? button.parentElement : null;
const hitSize = 36;
const iconSize = 24;
const baseColor = "#f6f7f8";
[wrap, button].filter(Boolean).forEach((node) => {
node.style.setProperty("--blr-player-ai-action-hit-size", `${hitSize}px`);
node.style.setProperty("--blr-player-ai-action-color", baseColor);
node.style.setProperty("--blr-player-ai-action-hover-color", baseColor);
});
button.style.setProperty("--blr-player-ai-action-icon-size", `${iconSize}px`);
}
async function handlePlayerAiQuickActionClick(event) {
event.preventDefault();
event.stopPropagation();
if (
state.playerAiQuickActionSubmitting ||
state.readingViewOpen ||
isReaderMode() ||
Date.now() < state.playerAiQuickActionSuppressedUntil
) {
return;
}
state.playerAiQuickActionSubmitting = true;
const button = event.currentTarget instanceof HTMLButtonElement ? event.currentTarget : null;
if (button) {
button.disabled = true;
}
try {
state.settings = await getSettings();
if (!state.settings?.enablePlayerAiQuickAction) {
throw new Error("AI 按钮未开启");
}
const resp = await sendRuntimeMessage({ type: "player-ai-quick-action" });
if (!resp?.ok) {
throw new Error(resp?.error || "打开 AI 侧边栏失败");
}
setMessage("已打开 AI 侧边栏并发送快捷提示词。");
} catch (error) {
setMessage(`AI 快捷操作失败:${getErrorMessage(error)}`);
} finally {
state.playerAiQuickActionSubmitting = false;
if (button) {
button.disabled = false;
}
}
}
function isVisibleReaderControl(node) {
if (!node || typeof node.getBoundingClientRect !== "function") {
return false;
}
const rect = node.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
return false;
}
const style = window.getComputedStyle(node);
return style.display !== "none" && style.visibility !== "hidden" && style.pointerEvents !== "none";
}
function normalizeReaderPlayerContainer(playerHost = state.readingPlayerHost) {
if (!playerHost) {
return;
}
restoreReaderPlayerContainer();
const adjusted = [];
let current = playerHost;
let depth = 0;
while (current && current !== document.body && depth < 12) {
const computed = window.getComputedStyle(current);
const className = typeof current.className === "string" ? current.className : "";
const isPlayerLayoutNode = current.matches?.(
".bpx-player-container, .bpx-player-video-area, .bpx-player-primary-area, .bpx-player-inner, .scroll-sticky, .player-wrap, #playerWrap, #bilibili-player"
);
const isExplicitMiniNode = current.matches?.(
".bpx-player-mini-warp, .bpx-player-mini-close, [class*='mini-player'], [class*='picture-in-picture']"
);
const hasFloatingPosition = computed.position === "fixed" || computed.position === "sticky";
const isMiniLike =
hasFloatingPosition ||
/mini|picture|float|fixed-player/i.test(className) ||
current.matches?.(".bpx-player-mini-warp, .bpx-player-mini-close");
const shouldReset = state.readingNativePageMode
? Boolean(isExplicitMiniNode || (isPlayerLayoutNode && isMiniLike))
: isPlayerLayoutNode || isMiniLike;
if (shouldReset) {
adjusted.push({
node: current,
position: current.style.position,
left: current.style.left,
top: current.style.top,
right: current.style.right,
bottom: current.style.bottom,
width: current.style.width,
height: current.style.height,
transform: current.style.transform,
margin: current.style.margin,
zIndex: current.style.zIndex
});
current.setAttribute("data-blr-reader-player-reset", "1");
current.style.setProperty("position", "static", "important");
current.style.setProperty("left", "auto", "important");
current.style.setProperty("top", "auto", "important");
current.style.setProperty("right", "auto", "important");
current.style.setProperty("bottom", "auto", "important");
current.style.setProperty("transform", "none", "important");
current.style.setProperty("margin", "0", "important");
current.style.setProperty("z-index", "auto", "important");
if (current !== playerHost) {
current.style.removeProperty("width");
current.style.removeProperty("height");
}
}
current = current.parentElement;
depth += 1;
}
state.readingPlayerAdjustedNodes = adjusted;
}
function restoreReaderPlayerContainer() {
const adjusted = Array.isArray(state.readingPlayerAdjustedNodes) ? state.readingPlayerAdjustedNodes : [];
adjusted.forEach((item) => {
const node = item?.node;
if (!node?.isConnected) {
return;
}
node.style.position = item.position || "";
node.style.left = item.left || "";
node.style.top = item.top || "";
node.style.right = item.right || "";
node.style.bottom = item.bottom || "";
node.style.width = item.width || "";
node.style.height = item.height || "";
node.style.transform = item.transform || "";
node.style.margin = item.margin || "";
node.style.zIndex = item.zIndex || "";
node.removeAttribute("data-blr-reader-player-reset");
});
state.readingPlayerAdjustedNodes = [];
}
function alignReaderViewportToPlayer() {
if (!isReaderMode()) {
return;
}
// Bilibili may enable smooth scrolling on the document. Repeatedly aligning
// to a moving title/player anchor made the whole reader drift upward during
// its first seconds. Reset once, synchronously, before locking the layout.
const root = document.documentElement;
const previousScrollBehavior = root.style.getPropertyValue("scroll-behavior");
const previousPriority = root.style.getPropertyPriority("scroll-behavior");
root.style.setProperty("scroll-behavior", "auto", "important");
root.scrollTop = 0;
document.body.scrollTop = 0;
window.scrollTo(0, 0);
window.requestAnimationFrame(() => {
if (previousScrollBehavior) {
root.style.setProperty("scroll-behavior", previousScrollBehavior, previousPriority);
} else {
root.style.removeProperty("scroll-behavior");
}
if (state.readingViewOpen && isReaderMode()) {
layoutReaderPlayerHost();
}
});
}
function syncReadingViewPlayback(forceScroll = false) {
if (!state.readingViewOpen) {
return;
}
if (state.readingNativePageMode) {
layoutReaderPlayerHost();
}
const runtimeVideo = getRuntimeVideoElement();
const runtimeHost = findReaderPlayerHost(runtimeVideo);
if (runtimeVideo && runtimeHost) {
const playerChanged =
runtimeVideo !== state.readingVideoEl || runtimeHost !== state.readingPlayerHost;
if (playerChanged) {
queueEnsureReaderPlayerMounted();
}
}
const video = bindReadingViewVideo(runtimeVideo || state.readingVideoEl);
if (!video) {
renderReadingStatus("当前页面没有找到可联动的视频播放器。");
return;
}
const currentTime = Number(video.currentTime || 0) || 0;
const subtitleIndex = findActiveSubtitleIndex(currentTime);
const chapterIndex = findActiveChapterIndex(currentTime);
const changed =
subtitleIndex !== state.readingActiveSubtitleIndex ||
chapterIndex !== state.readingActiveChapterIndex;
setActiveReadingItems(subtitleIndex, chapterIndex, forceScroll || changed);
updateReaderFollowState();
renderReadingStatus(`当前进度 ${formatCompactTimestamp(currentTime, currentTime >= 3600)}`);
}
function findActiveSubtitleIndex(currentTime) {
const items = Array.isArray(state.subtitleBody) ? state.subtitleBody : [];
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
const from = Number(item?.from || 0) || 0;
const rawTo = Number(item?.to || 0) || 0;
const to = rawTo > from ? rawTo : from + 2;
if (currentTime >= from && currentTime < to) {
return index;
}
}
return -1;
}
function findActiveChapterIndex(currentTime) {
const chapters = normalizeChapters(state.chapters || []);
for (let index = 0; index < chapters.length; index += 1) {
const item = chapters[index];
const from = Number(item?.from || 0) || 0;
const next = chapters[index + 1];
const explicitTo = Number(item?.to || 0) || 0;
const fallbackTo = next && Number(next.from) > from ? Number(next.from) : explicitTo;
const to = fallbackTo > from ? fallbackTo : Number.POSITIVE_INFINITY;
if (currentTime >= from && currentTime < to) {
return index;
}
}
return -1;
}
function setActiveReadingItems(subtitleIndex, chapterIndex, shouldScroll = false) {
const transcriptList = byId(ids.readingTranscriptList);
const chapterList = byId(ids.readingChapterList);
const nextTranscript = transcriptList.querySelector(`[data-index="${subtitleIndex}"]`);
const nextChapter = chapterList.querySelector(`[data-index="${chapterIndex}"]`);
const currentTranscript = transcriptList.querySelector(
".blr-reading-item.is-active, .blr-reading-complete-segment.is-active"
);
const currentChapter = chapterList.querySelector(".blr-reading-chapter.is-active");
if (currentTranscript && currentTranscript !== nextTranscript) {
currentTranscript.classList.remove("is-active");
}
if (currentChapter && currentChapter !== nextChapter) {
currentChapter.classList.remove("is-active");
}
if (nextTranscript) {
nextTranscript.classList.add("is-active");
}
if (nextChapter) {
nextChapter.classList.add("is-active");
}
if (shouldScroll && state.readingAutoScroll) {
if (Date.now() < state.readingManualScrollPauseUntil) {
updateReaderFollowState();
state.readingActiveSubtitleIndex = subtitleIndex;
state.readingActiveChapterIndex = chapterIndex;
return;
}
if (nextTranscript) {
scrollReadingTranscriptItemIntoView(nextTranscript);
}
if (nextChapter) {
scrollReadingRailItemIntoView(nextChapter);
}
}
state.readingActiveSubtitleIndex = subtitleIndex;
state.readingActiveChapterIndex = chapterIndex;
}
function scrollReadingRailItemIntoView(node) {
if (!node) {
return;
}
state.readingProgrammaticScrollUntil = Date.now() + 600;
node.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest"
});
}
function scrollReadingTranscriptItemIntoView(node) {
if (!node) {
return;
}
const transcriptList = byId(ids.readingTranscriptList);
const inlineHost = document.getElementById("blr-reading-inline-host");
const listRect = transcriptList.getBoundingClientRect();
const itemRect = node.getBoundingClientRect();
if (!(listRect.height > 0) || !(itemRect.height > 0)) {
scrollReadingRailItemIntoView(node);
return;
}
const behavior = state.readingNextScrollBehavior === "auto" ? "auto" : "smooth";
state.readingProgrammaticScrollUntil = Date.now() + (behavior === "auto" ? 120 : 800);
state.readingNextScrollBehavior = "smooth";
if (state.readingNativePageMode && inlineHost && inlineHost.scrollHeight > inlineHost.clientHeight + 8) {
const hostRect = inlineHost.getBoundingClientRect();
const computed = window.getComputedStyle(node);
const lineHeight = Number.parseFloat(computed.lineHeight) || itemRect.height || 32;
const heading = document.getElementById("blr-reading-transcript-heading");
const headingHeight = heading?.getBoundingClientRect?.().height || 38;
let anchorNode = node;
if (!node.classList.contains("blr-reading-complete-segment")) {
for (let count = 0; count < 2 && anchorNode.previousElementSibling; count += 1) {
anchorNode = anchorNode.previousElementSibling;
}
}
const anchorRect = anchorNode.getBoundingClientRect();
const isCompleteMode = node.classList.contains("blr-reading-complete-segment");
const desiredOffset = headingHeight + 8 + (isCompleteMode ? lineHeight * 2 : 0);
const targetScrollTop =
inlineHost.scrollTop + (anchorRect.top - hostRect.top) - desiredOffset;
inlineHost.scrollTo({
top: Math.max(0, Math.round(targetScrollTop)),
behavior
});
return;
}
if (state.readingNativePageMode || transcriptList.scrollHeight <= transcriptList.clientHeight + 8) {
const desiredTop = listRect.top + Math.max(72, Math.min(listRect.height * 0.24, 220));
const nextTop = window.scrollY + itemRect.top - desiredTop;
window.scrollTo({
top: Math.max(0, Math.round(nextTop)),
behavior
});
return;
}
const targetScrollTop =
transcriptList.scrollTop + (itemRect.top - listRect.top) - Math.max(48, Math.min(listRect.height * 0.24, 180));
transcriptList.scrollTo({
top: Math.max(0, Math.round(targetScrollTop)),
behavior
});
}
function jumpReadingTarget(seconds) {
const video = bindReadingViewVideo();
if (!video) {
renderReadingStatus("当前页面没有找到可联动的视频播放器。");
return;
}
const nextTime = Math.max(0, Number(seconds || 0) || 0);
state.readingManualScrollPauseUntil = 0;
state.readingNextScrollBehavior = "auto";
updateReaderFollowState();
video.currentTime = nextTime;
if (video.paused) {
video.play().catch(() => {});
}
syncReadingViewPlayback(true);
}
function onReadingChapterClick(event) {
const target = event.target.closest(".blr-reading-chapter");
if (!target) {
return;
}
jumpReadingTarget(target.dataset.seconds);
}
function onReadingTranscriptClick(event) {
const target = event.target.closest(".blr-reading-item, .blr-reading-complete-segment");
if (!target) {
return;
}
// Don't jump if user is selecting text
if (window.getSelection()?.toString().trim()) {
return;
}
jumpReadingTarget(target.dataset.seconds);
}
function noteManualReaderInteraction(durationMs = 3000) {
if (!state.readingAutoScroll) {
updateReaderFollowState();
return;
}
state.readingManualScrollPauseUntil = Date.now() + durationMs;
updateReaderFollowState();
}
function updateReaderFollowState() {
const readingView = document.getElementById(ids.readingView);
if (!readingView) {
return;
}
const mode =
!state.readingAutoScroll ? "off" : Date.now() < state.readingManualScrollPauseUntil ? "manual" : "auto";
readingView.setAttribute("data-blr-reader-follow", mode);
}
function computeCurrentClipSignature(url = location.href) {
const bvid = extractBvid(url);
const page = extractPageIndex(url);
return [bvid, page].map((item) => String(item || "").trim()).join("|");
}
function toReadableText(value, fallback = "") {
if (value === undefined || value === null) {
return fallback;
}
if (typeof value === "string") {
const text = value.trim();
if (!text || text === "[object Object]") {
return fallback;
}
return text;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
const json = JSON.stringify(value);
if (json && json !== "{}") {
return json;
}
} catch {
// ignore
}
const text = String(value);
if (!text || text === "[object Object]") {
return fallback;
}
return text;
}
function getErrorMessage(error, fallback = "未知错误") {
const code = toReadableText(error?.code, "");
const message = toReadableText(error?.message, "");
if (message) {
return code ? `${message} (code: ${code})` : message;
}
if (code) {
return `code: ${code}`;
}
return toReadableText(error, fallback);
}
function sendRuntimeMessage(message) {
return new Promise((resolve, reject) => {
try {
chrome.runtime.sendMessage(message, (resp) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve(resp);
});
} catch (error) {
reject(error);
}
});
}
function isExtensionContextInvalidated(error) {
const msg = String(error?.message || "");
return msg.includes("Extension context invalidated");
}
function requestOpenOptions() {
sendRuntimeMessage({ type: "open-options" })
.then((resp) => {
if (!resp?.ok) {
setMessage(`打开设置失败:${toReadableText(resp?.error, "未知错误")}`);
}
})
.catch((error) => {
if (isExtensionContextInvalidated(error)) {
setMessage("扩展刚刚更新,请刷新当前页面后重试。");
return;
}
setMessage(`打开设置失败:${getErrorMessage(error)}`);
});
}
async function getSettings() {
try {
const response = await sendRuntimeMessage({ type: "get-settings" });
if (!response?.ok) {
return { ...DEFAULT_SETTINGS };
}
const migration = migrateReaderDefaults(response.settings || {});
if (migration.changed) {
await sendRuntimeMessage({ type: "save-settings", settings: migration.settings }).catch(() => {});
}
return migration.settings;
} catch (error) {
return { ...DEFAULT_SETTINGS };
}
}
function migrateReaderDefaults(savedSettings) {
const saved = savedSettings && typeof savedSettings === "object" ? savedSettings : {};
const merged = { ...DEFAULT_SETTINGS, ...saved };
const savedDefaultsVersion = Number(saved.readerDefaultsVersion || 0);
if (savedDefaultsVersion >= 3) {
return { settings: merged, changed: false };
}
const legacyDefaults = {
readerFontScale: "m",
readerFontWeight: "normal",
readerLetterSpacing: "normal",
readerLineHeight: "tight",
readerTranscriptMode: "fragmented"
};
const keys = Object.keys(legacyDefaults);
const usesLegacyDefaults = keys.every(
(key) => saved[key] === undefined || saved[key] === legacyDefaults[key]
);
if (savedDefaultsVersion < 2 && usesLegacyDefaults) {
merged.readerFontScale = DEFAULT_SETTINGS.readerFontScale;
merged.readerFontWeight = DEFAULT_SETTINGS.readerFontWeight;
merged.readerLetterSpacing = DEFAULT_SETTINGS.readerLetterSpacing;
merged.readerLineHeight = DEFAULT_SETTINGS.readerLineHeight;
merged.readerTranscriptMode = DEFAULT_SETTINGS.readerTranscriptMode;
}
// v3 将“未手动指定高度”的默认含义统一为当前窗口可用的最大高度。
// 清除旧版本曾保存的固定像素值,避免它继续覆盖新的最大高度默认值。
merged.readerVideoHeightPx = 0;
merged.readerDefaultsVersion = 3;
return { settings: merged, changed: true };
}
function byId(id) {
const node = document.getElementById(id);
if (!node) {
throw new Error(`Missing node: ${id}`);
}
return node;
}
function extractBvid(url) {
const match = url.match(/\/video\/(BV[0-9A-Za-z]+)/);
if (match?.[1]) {
return match[1];
}
try {
const parsed = new URL(url);
const fromQuery = String(parsed.searchParams.get("bvid") || "").trim();
if (/^BV[0-9A-Za-z]+$/.test(fromQuery)) {
return fromQuery;
}
} catch {
// ignore invalid URL
}
return "";
}
function cleanVideoUrl(href = location.href) {
try {
const parsed = new URL(href);
if (parsed.hostname !== "www.bilibili.com") {
return href;
}
if (parsed.pathname === "/list/watchlater" || parsed.pathname === "/list/watchlater/") {
const bvid = extractBvid(href);
if (bvid) {
return `https://www.bilibili.com/video/${bvid}/`;
}
return href;
}
const bvid = extractBvid(href);
if (!bvid) {
return href;
}
const p = parsed.searchParams.get("p");
const qs = p ? `?p=${encodeURIComponent(p)}` : "";
return `https://www.bilibili.com/video/${bvid}/${qs}`;
} catch {
return href;
}
}
function extractPageIndex(url) {
try {
const page = Number(new URL(url).searchParams.get("p") || "1");
if (!Number.isFinite(page) || page <= 0) {
return 1;
}
return page;
} catch {
return 1;
}
}
function hasExplicitPageParam(url) {
try {
return new URL(url).searchParams.has("p");
} catch {
return false;
}
}
function extractOid(url) {
try {
return String(new URL(url).searchParams.get("oid") || "").trim();
} catch {
return "";
}
}
function ensureRunActive(runId) {
if (runId !== state.fetchRunId) {
const error = new Error("Stale refresh run");
error.code = "STALE_RUN";
throw error;
}
}
function isStaleRunError(error) {
return error?.code === "STALE_RUN";
}
async function retryAsync(task, retries = 1, delayMs = 180) {
let lastError = null;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
return await task();
} catch (error) {
lastError = error;
// 如果不是网络错误也不是可重试的业务错误,立即抛出
const isNetworkError = isRetryableNetworkError(error);
const isRetryable = error?.retryable === true;
if (!isNetworkError && !isRetryable) {
throw error;
}
if (attempt >= retries) {
throw error;
}
// 指数退避:delayMs * 2^(attempt-1),最多等待 5 秒
const backoffDelay = Math.min(delayMs * Math.pow(2, attempt - 1), 5000);
logInfo(`[BOC] retrying after ${backoffDelay}ms, attempt ${attempt + 1}/${retries}`, {
error: getErrorMessage(error),
code: error.code
});
await sleep(backoffDelay);
}
}
throw lastError || new Error("Unknown retry error");
}
function isRetryableNetworkError(error) {
const message = getErrorMessage(error, "").toLowerCase();
if (!message) {
return false;
}
if (message.includes("http ")) {
return true;
}
return (
message.includes("请求失败") ||
message.includes("failed to fetch") ||
message.includes("fetch failed") ||
message.includes("networkerror") ||
message.includes("net::") ||
message.includes("background fetch failed") ||
message.includes("timeout") ||
message.includes("timed out")
);
}
async function sleep(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function fetchVideoMeta(bvid) {
const url = `https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`;
logInfo("[BOC] fetch video meta", { url, bvid });
const payload = await fetchJson(url);
if (payload.code !== 0) {
throw new Error(toReadableText(payload?.message, "无法获取视频信息"));
}
const data = payload.data || {};
const pubdate = Number(data.pubdate || 0);
const uploadDate = pubdate > 0 ? formatLocalDate(pubdate * 1000) : "";
const pages = Array.isArray(data.pages) ? data.pages : [];
return {
aid: data.aid ? String(data.aid) : "",
title: String(data.title || ""),
author: String(data.owner?.name || ""),
description: String(data.desc || ""),
uploadDate,
defaultCid: data.cid ? String(data.cid) : "",
defaultDuration: Number(data.duration || 0) || 0,
pages: pages.map((item) => ({
cid: String(item.cid || ""),
page: Number(item.page || 0) || 0,
part: String(item.part || "").trim(),
duration: Number(item.duration || 0) || 0
}))
};
}
function pickPageFromPages(pages, pageIndex) {
const safePageIndex = Number(pageIndex) > 0 ? Number(pageIndex) : 1;
const safePages = Array.isArray(pages) ? pages : [];
const pageByIndex = safePages[safePageIndex - 1];
if (pageByIndex?.cid) {
return pageByIndex;
}
const pageByNo = safePages.find((item) => Number(item.page) === safePageIndex);
if (pageByNo?.cid) {
return pageByNo;
}
return null;
}
function pickCidFromPages(pages, pageIndex, fallbackCid = "") {
const matchedPage = pickPageFromPages(pages, pageIndex);
if (matchedPage?.cid) {
return String(matchedPage.cid);
}
const safePages = Array.isArray(pages) ? pages : [];
if (safePages[0]?.cid) {
return String(safePages[0].cid);
}
if (fallbackCid) {
return String(fallbackCid);
}
throw new Error("没有找到当前分P的 CID。");
}
function pickPageIndexFromOid(pages, oid) {
const safeOid = String(oid || "").trim();
if (!safeOid) {
return 0;
}
const safePages = Array.isArray(pages) ? pages : [];
const pageByCid = safePages.find((item) => String(item?.cid || "") === safeOid);
if (pageByCid?.page) {
return Number(pageByCid.page) || 0;
}
return 0;
}
function pickDurationFromPages(pages, pageIndex, fallbackDuration = 0) {
const matchedPage = pickPageFromPages(pages, pageIndex);
if (Number(matchedPage?.duration) > 0) {
return Number(matchedPage.duration);
}
const safePages = Array.isArray(pages) ? pages : [];
if (Number(safePages[0]?.duration) > 0) {
return Number(safePages[0].duration);
}
return Number(fallbackDuration || 0) || 0;
}
function readVideoTitle() {
const h1 = document.querySelector("h1.video-title");
if (h1?.textContent?.trim()) {
return h1.textContent.trim();
}
const metaTitle = document.querySelector('meta[property="og:title"]');
if (metaTitle?.getAttribute("content")) {
return metaTitle.getAttribute("content").trim();
}
return document.title.replace(/_哔哩哔哩_bilibili/i, "").trim();
}
function readVideoAuthor() {
const owner = document.querySelector(".up-name");
if (owner?.textContent?.trim()) {
return owner.textContent.trim();
}
const author = document.querySelector('meta[name="author"]');
return author?.getAttribute("content")?.trim() || "";
}
function readVideoDescription() {
const descNode = document.querySelector(
".desc-info-text, .video-desc .desc-info-text, .video-info-detail .text, .basic-desc-info"
);
return descNode?.textContent?.trim() || "";
}
function readUploadDate() {
const publishNode = document.querySelector('meta[itemprop="uploadDate"]');
if (publishNode?.getAttribute("content")) {
return publishNode.getAttribute("content").trim();
}
const dateText = document.querySelector(".pubdate-ip-text")?.textContent?.trim();
if (dateText) {
return dateText;
}
return formatLocalDate();
}
async function fetchSubtitleBundle(bvid, cid, aid = "") {
const requests = buildSubtitleInfoRequests({ bvid, cid, aid });
const fetchByRequest = async (request) => {
logInfo("[BOC] fetch subtitles list", {
source: request.source,
url: request.url,
bvid,
cid,
aid
});
const payload = await fetchJson(request.url);
logInfo("[BOC] subtitles API raw response", { source: request.source, payload });
if (payload.code !== 0) {
throw buildBiliApiError(payload, "无法获取字幕列表");
}
const chapters = mapChaptersFromPlayerData(payload.data);
const subtitles = mapSubtitleTracks(payload.data?.subtitle?.subtitles || [], request.source);
const withUrl = subtitles.filter((item) => item.subtitleUrl);
return { source: request.source, chapters, withUrl };
};
if (requests.length === 0) {
return { tracks: [], chapters: [] };
}
const primaryRequest = requests[0];
try {
const primaryResult = await fetchByRequest(primaryRequest);
if (primaryResult.withUrl.length > 0) {
return { tracks: primaryResult.withUrl, chapters: primaryResult.chapters };
}
// 主来源成功但无字幕:直接判定无字幕,不再跨源兜底。
return { tracks: [], chapters: primaryResult.chapters };
} catch (primaryError) {
logWarn("[BOC] subtitles API request failed", {
source: primaryRequest.source,
message: getErrorMessage(primaryError)
});
// 仅当主来源请求失败时才尝试次来源。
if (requests.length > 1) {
const secondaryRequest = requests[1];
try {
const secondaryResult = await fetchByRequest(secondaryRequest);
if (secondaryResult.withUrl.length > 0) {
logWarn("[BOC] primary subtitles source failed, using fallback source", {
primary: primaryRequest.source,
fallback: secondaryRequest.source
});
return { tracks: secondaryResult.withUrl, chapters: secondaryResult.chapters };
}
return { tracks: [], chapters: secondaryResult.chapters };
} catch (secondaryError) {
logWarn("[BOC] fallback subtitles source failed", {
source: secondaryRequest.source,
message: getErrorMessage(secondaryError)
});
throw secondaryError;
}
}
throw primaryError;
}
}
function buildSubtitleInfoRequests({ bvid, cid, aid }) {
const safeBvid = encodeURIComponent(String(bvid || ""));
const safeCid = encodeURIComponent(String(cid || ""));
const safeAid = encodeURIComponent(String(aid || ""));
const requests = [];
// 参考 SubBatch:优先用 aid+cid 的 wbi 接口作为主来源。
if (aid) {
requests.push({
source: "player-wbi-v2",
url:
"https://api.bilibili.com/x/player/wbi/v2" +
`?aid=${safeAid}` +
`&cid=${safeCid}` +
(bvid ? `&bvid=${safeBvid}` : "")
});
}
// 仅在主来源不可用时再回退到 player-v2。
requests.push({
source: "player-v2",
url:
"https://api.bilibili.com/x/player/v2" +
(bvid ? `?bvid=${safeBvid}` : "?") +
`${bvid ? "&" : ""}cid=${safeCid}` +
(aid ? `&aid=${safeAid}` : "")
});
return requests;
}
function buildBiliApiError(payload, fallbackMessage) {
const msg = toReadableText(payload?.message, fallbackMessage);
const error = new Error(msg);
error.code = payload?.code;
error.retryable = isRetryableError(payload?.code);
return error;
}
function mapSubtitleTracks(subtitles, source = "unknown") {
return (subtitles || []).map((item) => ({
id: item?.id === undefined || item?.id === null ? "" : String(item.id),
lan: item?.lan || "",
lanDoc: item?.lan_doc || "",
subtitleUrl: normalizeSubtitleUrl(item?.subtitle_url || ""),
source
}));
}
function mapChaptersFromPlayerData(data) {
const raw = Array.isArray(data?.view_points) ? data.view_points : [];
return normalizeChapters(
raw.map((item) => ({
title: String(item?.content || item?.title || item?.label || "").trim(),
from: normalizeChapterTime(item?.from ?? item?.start ?? item?.start_time),
to: normalizeChapterTime(item?.to ?? item?.end ?? item?.end_time),
source: "player-view-points"
}))
);
}
function normalizeChapterTime(value) {
if (value === undefined || value === null || value === "") {
return 0;
}
const num = Number(value);
if (!Number.isFinite(num) || num < 0) {
return 0;
}
// 某些接口会返回毫秒级时间戳,这里统一转换成秒。
return num > 60 * 60 * 24 ? num / 1000 : num;
}
function normalizeChapters(chapters) {
const normalized = (chapters || [])
.map((item) => ({
title: String(item?.title || "").trim(),
from: Number(item?.from || 0) || 0,
to: Number(item?.to || 0) || 0,
source: String(item?.source || "")
}))
.filter((item) => item.title && item.from >= 0)
.sort((a, b) => a.from - b.from);
const unique = [];
const seen = new Set();
normalized.forEach((item) => {
const key = `${Math.floor(item.from * 10)}|${item.title.toLowerCase()}`;
if (seen.has(key)) {
return;
}
seen.add(key);
unique.push(item);
});
return unique;
}
function isRetryableError(code) {
// -509: 请求过于频繁
// -3: 参数错误(可能是临时性的)
// 其他负数错误码也可能是临时性的
return code === -509 || code === -3 || code < 0;
}
function normalizeSubtitleTracks(subtitles) {
return [...(subtitles || [])].sort((a, b) => {
const p = subtitlePriority(a) - subtitlePriority(b);
if (p !== 0) {
return p;
}
const lanA = String(a.lanDoc || a.lan || "").toLowerCase();
const lanB = String(b.lanDoc || b.lan || "").toLowerCase();
if (lanA < lanB) {
return -1;
}
if (lanA > lanB) {
return 1;
}
const idA = Number.parseInt(String(a.id || "0"), 10);
const idB = Number.parseInt(String(b.id || "0"), 10);
if (Number.isFinite(idA) && Number.isFinite(idB) && idA !== idB) {
return idA - idB;
}
return String(a.subtitleUrl).localeCompare(String(b.subtitleUrl));
});
}
function pickPreferredSubtitle(
subtitles,
{ previousId = "", previousUrl = "", previousLang = "" } = {}
) {
const tracks = subtitles || [];
if (tracks.length === 0) {
return null;
}
// 先按轨道 id 复用,最稳定
if (previousId) {
const byId = tracks.find((item) => String(item.id || "") === String(previousId));
if (byId) {
return byId;
}
}
// 其次按 URL 路径复用(忽略 auth_key 等动态参数)
const prevUrlKey = normalizeSubtitleUrlForCache(previousUrl);
if (prevUrlKey) {
const byUrl = tracks.find(
(item) => normalizeSubtitleUrlForCache(item.subtitleUrl) === prevUrlKey
);
if (byUrl) {
return byUrl;
}
}
const normalizedPrevLang = String(previousLang || "").trim().toLowerCase();
if (normalizedPrevLang) {
const byLang = tracks.find((item) => {
const label = String(item.lanDoc || item.lan || "").trim().toLowerCase();
return label === normalizedPrevLang;
});
if (byLang) {
return byLang;
}
}
// 默认直接拿排序后的第一条:中文优先,其次英文。
return tracks[0];
}
function buildSubtitleCandidates(subtitles, preferred) {
const tracks = subtitles || [];
const seen = new Set();
const list = [];
const pushUnique = (item) => {
if (!item) {
return;
}
const key =
`${String(item.id || "").trim()}|` +
`${normalizeSubtitleUrlForCache(item.subtitleUrl)}|` +
`${String(item.lan || "").trim().toLowerCase()}`;
if (seen.has(key)) {
return;
}
seen.add(key);
list.push(item);
};
pushUnique(preferred);
for (const item of tracks) {
pushUnique(item);
}
return list;
}
async function tryLoadSubtitleCandidates(candidates, runId, forceRefresh) {
let lastError = null;
for (const item of candidates || []) {
try {
logInfo("[BOC] try subtitle track", {
id: item.id,
lan: item.lan,
lanDoc: item.lanDoc,
url: item.subtitleUrl
});
await loadSubtitle(
item.subtitleUrl,
item.lanDoc || item.lan || "unknown",
runId,
item.id,
forceRefresh
);
return item;
} catch (error) {
lastError = error;
const reasonCode = toReadableText(error?.code, "");
const reasonMessage = getErrorMessage(error, "unknown");
const meta = {
id: item.id,
lan: item.lan,
lanDoc: item.lanDoc,
reason: reasonCode || reasonMessage
};
if (reasonCode === "SUBTITLE_DURATION_MISMATCH") {
logInfo(`[BOC] subtitle track skipped ${JSON.stringify(meta)}`);
} else {
logWarn(`[BOC] subtitle track rejected ${JSON.stringify(meta)}`);
}
ensureRunActive(runId);
continue;
}
}
if (lastError) {
throw lastError;
}
throw new Error("这个视频暂时没有可用字幕。");
}
function isAiSubtitle(item) {
const lan = String(item?.lan || "").toLowerCase();
// B站 AI 自动字幕的 lan 以 "ai-" 开头
return lan.startsWith("ai-");
}
function subtitlePriority(item) {
const lan = String(item?.lan || "").toLowerCase();
const label = String(item?.lanDoc || "").toLowerCase();
// 优先级:中文(包含 AI 中文)-> 英文 -> 其他
if (lan === "zh-cn" || lan === "zh-hans") {
return 0;
}
if (lan === "zh") {
return 1;
}
if (lan.includes("zh")) {
return 2;
}
if (label.includes("中文")) {
return 3;
}
if (lan === "en" || lan === "en-us" || lan === "en-gb") {
return 10;
}
if (lan.includes("en")) {
return 11;
}
if (label.includes("英文") || label.includes("英语") || label.includes("english")) {
return 12;
}
return 50;
}
function validateSubtitleByDuration(body, videoDuration) {
const duration = Number(videoDuration || 0);
if (!Array.isArray(body) || body.length === 0) {
return { ok: false, reason: "empty", videoDuration: duration, maxTo: 0 };
}
let maxTo = 0;
for (const item of body) {
const to = Number(item?.to);
const from = Number(item?.from);
if (Number.isFinite(to) && to > maxTo) {
maxTo = to;
}
if (Number.isFinite(from) && from > maxTo) {
maxTo = from;
}
}
if (!(duration > 0)) {
return { ok: true, reason: "skip-no-video-duration", videoDuration: duration, maxTo };
}
const upperTolerance = Math.max(12, duration * 0.15);
if (maxTo > duration + upperTolerance) {
return { ok: false, reason: "too-long", videoDuration: duration, maxTo };
}
let minCoverageRatio = 0;
if (duration >= 600) {
minCoverageRatio = 0.18;
} else if (duration >= 300) {
minCoverageRatio = 0.22;
} else if (duration >= 180) {
minCoverageRatio = 0.25;
}
if (minCoverageRatio > 0 && maxTo < duration * minCoverageRatio) {
return { ok: false, reason: "too-short", videoDuration: duration, maxTo };
}
return { ok: true, reason: "ok", videoDuration: duration, maxTo };
}
function readRuntimeVideoDuration() {
const video = getRuntimeVideoElement();
const duration = Number(video?.duration);
if (Number.isFinite(duration) && duration > 0) {
return duration;
}
return 0;
}
async function fetchSubtitleBody(url) {
logInfo("[BOC] fetch subtitle body", { url });
return fetchJsonInBackground(url);
}
async function fetchJson(url) {
if (typeof url === "string" && url.startsWith("https://api.bilibili.com/")) {
return fetchJsonInBackground(url);
}
const response = await fetch(url, {
credentials: "include",
cache: "no-store"
});
if (!response.ok) {
throw new Error(`请求失败:${response.status}`);
}
return response.json();
}
async function fetchJsonInBackground(url) {
try {
const resp = await sendRuntimeMessage({ type: "fetch-json", url });
if (!resp?.ok) {
throw new Error(toReadableText(resp?.error, "Background fetch failed"));
}
return resp.data;
} catch (error) {
if (isExtensionContextInvalidated(error)) {
throw new Error("扩展刚刚更新,请刷新当前页面后重试。");
}
throw error;
}
}
function normalizeHotComments(comments, limit = 20) {
if (!Array.isArray(comments)) {
return [];
}
return comments
.map((item) => ({
uname: String(item?.uname || "匿名").trim() || "匿名",
like: Number(item?.like || 0) || 0,
message: String(item?.message || "").trim().slice(0, 500)
}))
.filter((item) => item.message)
.slice(0, limit);
}
function getCurrentAid() {
let aid = Number(state.aid) || 0;
if (!aid && typeof window !== "undefined") {
try {
aid = Number(window?.__INITIAL_STATE__?.aid) || 0;
} catch {}
}
return aid;
}
async function fetchHotComments(count = 20) {
const safeCount = Math.max(0, Number(count) || 0);
if (!safeCount) {
return [];
}
const aid = getCurrentAid();
if (!aid) {
return [];
}
const url = `https://api.bilibili.com/x/v2/reply/main?type=1&oid=${aid}&mode=3&ps=${safeCount}&pn=1`;
const resp = await sendRuntimeMessage({ type: "fetch-json", url });
if (!resp?.ok) {
throw new Error(resp?.error || "评论接口失败");
}
const replies = Array.isArray(resp?.data?.data?.replies) ? resp.data.data.replies : [];
return normalizeHotComments(
replies.map((item) => ({
uname: item?.member?.uname || "匿名",
like: item?.like || 0,
message: item?.content?.message || ""
})),
safeCount
);
}
function rebuildDerivedContent() {
const body = Array.isArray(state.subtitleBody) ? state.subtitleBody : [];
state.markdown = body.length ? buildMarkdown(state, body, state.settings) : "";
state.srt = body.length ? buildSrt(body) : "";
state.txt = body.length ? buildTxt(body, state.settings) : "";
byId(ids.preview).value = body.length ? buildSubtitlePreview(body, state.settings) : "";
}
async function refreshDerivedContent({ refreshComments = false } = {}) {
if (state.settings?.includeHotCommentsInNote) {
const shouldFetchComments =
refreshComments || !Array.isArray(state.hotComments) || state.hotComments.length === 0;
if (shouldFetchComments) {
try {
state.hotComments = await fetchHotComments(20);
} catch (error) {
state.hotComments = [];
logWarn("[BOC] failed to fetch hot comments for note export", error);
}
}
}
rebuildDerivedContent();
}
function normalizeSubtitleUrl(url) {
if (!url) {
return "";
}
if (url.startsWith("//")) {
return `https:${url}`;
}
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
return `https://${url.replace(/^\/+/, "")}`;
}
function buildSubtitlePreview(body, settings) {
const compactWithHours = shouldShowHoursInSubtitle(body);
return (body || [])
.map((item) => {
const text = String(item?.content || "").trim();
if (!text) {
return "";
}
if (settings.includeTimestampInBody) {
return `\`${formatCompactTimestamp(item.from, compactWithHours)}\` ${text}`;
}
return text;
})
.filter(Boolean)
.join("\n");
}
function buildMarkdown(meta, body, settings) {
const created = formatLocalDate();
const tags = (settings.tags || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
const tagsCsv = tags.join(", ");
const tagsYaml =
tags.length === 0 ? "[]" : `[${tags.map((tag) => `"${tag.replace(/"/g, '\\"')}"`).join(", ")}]`;
const compactWithHours = shouldShowHoursInNote(meta, body);
const chapterLines = buildChapterLines(meta.chapters || [], compactWithHours);
const subtitleSectionLines = buildSubtitleSectionLines(
body,
meta.chapters || [],
settings,
compactWithHours
);
const frontMatter = buildFrontMatter(meta, settings, created, tagsCsv, tagsYaml);
const page = extractPageIndex(location.href);
const embedIframe = buildBilibiliEmbedIframe(meta, page);
const intro = String(meta.description || "").trim();
const noteSectionContext = buildNotePlaceholderTemplateContext(meta, intro);
const noteSections = groupNotePlaceholderSections(settings, noteSectionContext);
const lines = [];
if (frontMatter) {
lines.push(frontMatter, "");
}
lines.push(embedIframe, "");
pushOptionalLines(lines, noteSections.before_intro);
if (intro) {
lines.push("## 简介", "", intro, "");
}
pushOptionalLines(lines, noteSections.before_chapters);
if (chapterLines.length > 0) {
lines.push("## 章节", "", ...chapterLines, "");
}
pushOptionalLines(lines, noteSections.before_subtitle);
lines.push("## 字幕", "", ...subtitleSectionLines);
const hotCommentLines = buildHotCommentLines(
settings?.includeHotCommentsInNote ? meta?.hotComments || [] : []
);
if (hotCommentLines.length > 0) {
lines.push("", "## 评论", "", ...hotCommentLines);
}
return lines.join("\n");
}
function buildHotCommentLines(comments) {
const items = normalizeHotComments(comments, 20);
if (items.length === 0) {
return [];
}
return items.flatMap((item, index) => [
`${index + 1}. ${item.uname}(赞 ${item.like})`,
item.message,
""
]).slice(0, -1);
}
function buildFrontMatter(meta, settings, created, tagsCsv, tagsYaml) {
const enabled = getEnabledFrontmatterFields(settings);
const fixedPropertyLines = getFixedFrontmatterPropertyLines(
settings,
buildFrontmatterTemplateContext(meta, created, tagsCsv, tagsYaml)
);
if (enabled.length === 0 && fixedPropertyLines.length === 0) {
return "";
}
const fieldLines = {
title: `title: "${escapeYaml(meta.title)}"`,
url: `url: "${escapeYaml(cleanVideoUrl())}"`,
bvid: `bvid: "${escapeYaml(meta.bvid)}"`,
cid: `cid: "${escapeYaml(meta.cid)}"`,
author: `author: "${escapeYaml(meta.author || "unknown")}"`,
upload_date: `upload_date: "${escapeYaml(meta.uploadDate || "unknown")}"`,
subtitle_lang: `subtitle_lang: "${escapeYaml(meta.selectedSubtitleLang || "unknown")}"`,
created: `created: "${created}"`,
tags: `tags: ${tagsYaml}`
};
const lines = enabled.map((field) => fieldLines[field]).filter(Boolean);
lines.push(...fixedPropertyLines);
if (lines.length === 0) {
return "";
}
return ["---", ...lines, "---"].join("\n");
}
function getEnabledFrontmatterFields(settings) {
const defaultFields = Array.isArray(DEFAULT_SETTINGS.frontmatterFields)
? DEFAULT_SETTINGS.frontmatterFields
: [];
const raw = Array.isArray(settings?.frontmatterFields) ? settings.frontmatterFields : defaultFields;
const allowed = new Set(defaultFields);
const unique = [];
raw.forEach((item) => {
const key = String(item || "").trim();
if (!key || !allowed.has(key) || unique.includes(key)) {
return;
}
unique.push(key);
});
return unique;
}
function getFixedFrontmatterPropertyLines(settings, templateContext = {}) {
const customPropertyKeyPattern = /^[\p{L}\p{N}_\-\s]+$/u;
const systemFields = new Set(
(Array.isArray(DEFAULT_SETTINGS.frontmatterFields) ? DEFAULT_SETTINGS.frontmatterFields : []).map((field) =>
String(field).toLowerCase()
)
);
const rows = Array.isArray(settings?.fixedFrontmatterProperties) ? settings.fixedFrontmatterProperties : [];
const seenKeys = new Set();
const lines = [];
rows.forEach((item) => {
const key = String(item?.key || "").trim();
const type = normalizeFixedPropertyType(item?.type);
const value = item?.value;
const lowerKey = key.toLowerCase();
if (!key || isFixedPropertyRowEffectivelyEmpty(type, value)) {
return;
}
if (!customPropertyKeyPattern.test(key)) {
return;
}
if (systemFields.has(lowerKey) || seenKeys.has(lowerKey)) {
return;
}
seenKeys.add(lowerKey);
const yamlLine = formatFixedPropertyYamlLine(key, type, value, templateContext);
if (yamlLine) {
lines.push(yamlLine);
}
});
return lines;
}
function normalizeFixedPropertyType(value) {
const type = String(value || "").trim().toLowerCase();
return type === "number" || type === "checkbox" || type === "list" || type === "date" ? type : "text";
}
function isFixedPropertyRowEffectivelyEmpty(type, value) {
return !String(value || "").trim();
}
function buildFrontmatterTemplateContext(meta, created, tagsCsv, tagsYaml) {
return {
title: String(meta?.title || "").trim(),
url: String(cleanVideoUrl() || "").trim(),
bvid: String(meta?.bvid || "").trim(),
cid: String(meta?.cid || "").trim(),
author: String(meta?.author || "unknown").trim(),
upload_date: String(meta?.uploadDate || "unknown").trim(),
subtitle_lang: String(meta?.selectedSubtitleLang || "unknown").trim(),
created: String(created || "").trim(),
tags: String(tagsCsv || "").trim(),
tags_csv: String(tagsCsv || "").trim(),
tags_yaml: String(tagsYaml || "").trim()
};
}
function sanitizeFolderTemplateValue(value) {
return String(value || "")
.replace(/[\/\\:*?"<>|]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function buildFolderTemplateContext(meta, created = formatLocalDate()) {
return {
created: sanitizeFolderTemplateValue(created),
upload_date: sanitizeFolderTemplateValue(meta?.uploadDate || ""),
author: sanitizeFolderTemplateValue(meta?.author || ""),
bvid: sanitizeFolderTemplateValue(meta?.bvid || "")
};
}
function resolveFolderTemplate(template, meta) {
const normalized = normalizeFolder(template);
if (!normalized) {
return "";
}
const allowedKeys = new Set(["created", "upload_date", "author", "bvid"]);
const context = buildFolderTemplateContext(meta);
const resolved = String(normalized).replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, rawKey) => {
const key = String(rawKey || "").trim().toLowerCase();
if (!allowedKeys.has(key)) {
return "";
}
return context[key] || "";
});
return resolved
.split("/")
.map((segment) => sanitizeFolderTemplateValue(segment))
.filter(Boolean)
.join("/");
}
function buildNotePlaceholderTemplateContext(meta, description) {
return {
title: String(meta?.title || "").trim(),
author: String(meta?.author || "").trim(),
url: String(cleanVideoUrl() || "").trim(),
upload_date: String(meta?.uploadDate || "").trim(),
description: String(description || "").trim()
};
}
function groupNotePlaceholderSections(settings, templateContext = {}) {
const groups = {
before_intro: [],
before_chapters: [],
before_subtitle: []
};
const rows = normalizeNotePlaceholderSections(settings?.notePlaceholderSections);
rows.forEach((item) => {
const renderedLines = buildNotePlaceholderLines(item, templateContext);
if (!renderedLines.length) {
return;
}
groups[item.position].push(...renderedLines);
});
return groups;
}
function buildNotePlaceholderLines(item, templateContext = {}) {
const title = String(item?.title || "").trim();
if (!title) {
return [];
}
const content = resolveFrontmatterTemplateValue(item?.content, templateContext).trim();
const lines = [`## ${title}`, ""];
if (content) {
lines.push(content, "");
}
return lines;
}
function pushOptionalLines(targetLines, extraLines) {
if (!Array.isArray(extraLines) || !extraLines.length) {
return;
}
targetLines.push(...extraLines);
}
function resolveFrontmatterTemplateValue(value, templateContext = {}) {
return String(value || "").replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, rawKey) => {
const key = String(rawKey || "").trim().toLowerCase();
if (!key) {
return "";
}
const resolved = templateContext[key];
return resolved == null ? "" : String(resolved);
});
}
function isYamlDateValue(value) {
return /^\d{4}-\d{2}-\d{2}$/.test(String(value || "").trim());
}
function parseFrontmatterArrayItems(value) {
return String(value || "")
.split(/[,,]/)
.map((item) => item.trim())
.filter(Boolean);
}
function formatFixedPropertyYamlLine(key, type, value, templateContext = {}) {
const normalizedType = normalizeFixedPropertyType(type);
const resolvedValue = resolveFrontmatterTemplateValue(value, templateContext).trim();
if (!resolvedValue) {
return "";
}
if (normalizedType === "number") {
const num = Number(resolvedValue);
if (!Number.isFinite(num)) {
return "";
}
return `${key}: ${resolvedValue}`;
}
if (normalizedType === "checkbox") {
const normalizedValue = resolvedValue.toLowerCase();
if (normalizedValue !== "true" && normalizedValue !== "false") {
return "";
}
return `${key}: ${normalizedValue}`;
}
if (normalizedType === "list") {
const items = parseFrontmatterArrayItems(resolvedValue);
return `${key}: [${items.map((item) => `"${escapeYaml(item)}"`).join(", ")}]`;
}
if (normalizedType === "date") {
if (!isYamlDateValue(resolvedValue)) {
return "";
}
return `${key}: ${resolvedValue}`;
}
return `${key}: "${escapeYaml(resolvedValue)}"`;
}
function normalizeNotePlaceholderSections(items) {
const allowedPositions = new Set(["before_intro", "before_chapters", "before_subtitle"]);
if (!Array.isArray(items)) {
return [];
}
return items
.map((item) => {
const title = String(item?.title || "").trim();
const position = allowedPositions.has(String(item?.position || "").trim())
? String(item?.position || "").trim()
: "before_intro";
const content = String(item?.content || "").trim();
return {
title,
position,
content
};
})
.filter((item) => item.title)
.slice(0, 5);
}
function buildSubtitleSectionLines(body, chapters, settings, withHours) {
const subtitleItems = (body || [])
.map((item, index) => ({
...item,
_index: index,
text: String(item?.content || "").trim()
}))
.filter((item) => item.text);
if (subtitleItems.length === 0) {
return ["(暂无字幕)"];
}
const chapterItems = normalizeChapters(chapters);
if (chapterItems.length === 0) {
return subtitleItems.map((item) => formatSubtitleLine(item, settings, withHours));
}
const lines = [];
const usedIndexes = new Set();
chapterItems.forEach((chapter, idx) => {
const start = Number(chapter.from || 0) || 0;
const next = chapterItems[idx + 1];
const chapterTo = Number(chapter.to || 0) || 0;
let end = Infinity;
if (next && Number(next.from) > start) {
end = Number(next.from);
} else if (chapterTo > start) {
end = chapterTo;
}
const sectionItems = subtitleItems.filter((item) => {
const from = Number(item.from || 0) || 0;
const inStart = from + 0.001 >= start;
const inEnd = end === Infinity ? true : from < end;
return inStart && inEnd;
});
if (sectionItems.length === 0) {
return;
}
const chapterStamp = settings.includeTimestampInBody
? ` \`${formatCompactTimestamp(start, withHours)}\``
: "";
lines.push(`### ${chapter.title}${chapterStamp}`, "");
sectionItems.forEach((item) => {
usedIndexes.add(item._index);
lines.push(formatSubtitleLine(item, settings, withHours));
});
lines.push("");
});
const remaining = subtitleItems.filter((item) => !usedIndexes.has(item._index));
if (remaining.length > 0) {
lines.push("### 其他片段", "");
remaining.forEach((item) => {
lines.push(formatSubtitleLine(item, settings, withHours));
});
lines.push("");
}
if (lines.length === 0) {
return subtitleItems.map((item) => formatSubtitleLine(item, settings, withHours));
}
while (lines.length > 0 && !lines[lines.length - 1]) {
lines.pop();
}
return lines;
}
function formatSubtitleLine(item, settings, withHours) {
const text = String(item?.content || "").trim();
if (!text) {
return "";
}
if (!settings.includeTimestampInBody) {
return text;
}
return `\`${formatCompactTimestamp(item.from, withHours)}\` ${text}`;
}
function buildChapterLines(chapters, withHours = false) {
const chapterItems = normalizeChapters(chapters);
if (chapterItems.length === 0) {
return [];
}
return chapterItems.map((item) => {
const fromText = formatCompactTimestamp(item.from, withHours);
return `- \`${fromText}\` ${item.title}`;
});
}
function buildBilibiliEmbedIframe(meta, page = 1) {
const safeAid = encodeURIComponent(String(meta?.aid || "").trim());
const safeBvid = encodeURIComponent(String(meta?.bvid || "").trim());
const safeCid = encodeURIComponent(String(meta?.cid || "").trim());
const safePage = Number(page) > 0 ? Number(page) : 1;
return ``;
}
function buildSrt(body) {
return body
.map((item, index) => {
const from = formatTimestamp(item.from, true);
const to = formatTimestamp(item.to, true);
const text = (item.content || "").trim();
return `${index + 1}\n${from} --> ${to}\n${text}`;
})
.join("\n\n");
}
function buildTxt(body, settings) {
const withHours = shouldShowHoursInSubtitle(body);
return (body || [])
.map((item) => {
const text = String(item?.content || "").trim();
if (!text) {
return "";
}
if (!settings?.includeTimestampInBody) {
return text;
}
return `${formatCompactTimestamp(item.from, withHours)} ${text}`;
})
.filter(Boolean)
.join("\n");
}
function shouldShowHoursInSubtitle(body) {
const maxTo = (body || []).reduce((max, item) => {
const to = Number(item?.to || 0);
return Number.isFinite(to) && to > max ? to : max;
}, 0);
return maxTo >= 3600;
}
function shouldShowHoursInNote(meta, body) {
const subtitleMaxTo = (body || []).reduce((max, item) => {
const to = Number(item?.to || 0);
return Number.isFinite(to) && to > max ? to : max;
}, 0);
const chapterMaxTo = normalizeChapters(meta?.chapters || []).reduce((max, item) => {
const from = Number(item?.from || 0) || 0;
const to = Number(item?.to || 0) || 0;
return Math.max(max, from, to);
}, 0);
const duration = Number(meta?.videoDuration || 0) || 0;
return Math.max(subtitleMaxTo, chapterMaxTo, duration) >= 3600;
}
function formatCompactTimestamp(seconds, withHours) {
const safe = Math.max(0, Math.floor(Number(seconds) || 0));
const hour = Math.floor(safe / 3600);
const minute = Math.floor((safe % 3600) / 60);
const second = safe % 60;
if (withHours) {
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:${String(
second
).padStart(2, "0")}`;
}
const totalMinutes = Math.floor(safe / 60);
return `${String(totalMinutes).padStart(2, "0")}:${String(second).padStart(2, "0")}`;
}
function formatTimestamp(seconds, forSrt = false) {
const safe = Number(seconds) || 0;
const msTotal = Math.max(0, Math.floor(safe * 1000));
const hour = Math.floor(msTotal / 3600000);
const minute = Math.floor((msTotal % 3600000) / 60000);
const second = Math.floor((msTotal % 60000) / 1000);
const ms = msTotal % 1000;
const hh = String(hour).padStart(2, "0");
const mm = String(minute).padStart(2, "0");
const ss = String(second).padStart(2, "0");
if (!forSrt) {
return `${hh}:${mm}:${ss}.${String(ms).padStart(3, "0")}`;
}
return `${hh}:${mm}:${ss},${String(ms).padStart(3, "0")}`;
}
function sanitizeFileName(value) {
return value.replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, " ").trim().slice(0, 120);
}
function normalizeDownloadFormat(value) {
return value === "txt" ? "txt" : "srt";
}
function buildNoteFilename(meta) {
const includeDate = state.settings?.includeDateInFilename !== false;
const baseParts = [];
if (includeDate) {
baseParts.push(formatLocalDate());
}
baseParts.push(meta.title || meta.bvid || "bilibili-subtitle");
if (Number(meta.pageCount) > 1) {
baseParts.push(`P${Number(meta.pageIndex) > 0 ? Number(meta.pageIndex) : 1}`);
const pageTitle = String(meta.pageTitle || "").trim();
if (pageTitle) {
baseParts.push(pageTitle);
}
}
const baseName = sanitizeFileName(baseParts.filter(Boolean).join("-"));
return `${baseName || "bilibili-subtitle"}.md`;
}
function normalizeFolder(input) {
return String(input || "").trim().replace(/^\/+|\/+$/g, "");
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function escapeYaml(value) {
return String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
})();
})();