// ==UserScript== // @name 小书吏 | 将哔哩哔哩与YouTube视频誊写为文章 // @namespace https://github.com/bilibli-reader // @version 0.0.10 // @description 将哔哩哔哩与 YouTube 视频字幕誊写为可阅读的文章,支持连续字幕、章节联动、点击跳转与语言切换 // @author Wow-CaiCai // @license MIT // @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/watchlater/* // @match https://www.bilibili.com/list/watchlater* // @match https://www.youtube.com/* // @match https://youtube.com/* // @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAFHUlEQVR4nO2ZTWhUVxTH/+fed99MMk4+KhNrRFE0mBhdaCVYuphaBNtFa2kdqYqlqIuiCKUbwU0qFOqi0rrqQil1ozQjRdHaUig6pVCtiKT1I2mrbfzERGOcfM3Me/ecLsbRxMR0JkYHy/zg8e77POd/73nnfjygRIkSzzQ04kwsph/3pdHOTkokXmZgO2fPNKto9LiqqamReM7Mve1h4ve2RxKP26GHIwX814U8kPulZpXdZ4XQqPfkZ/tR99OwcjSq63nKzp5k36x0KsUgKlgHQaW11m0zplXvO/3D/jYAWLB07dzOW91rfM9vIOIACzAnXI7aqkrA0YAwQArie+jo7kFHfxoOPXCaRaQsEFQVFaF/alTnh4lEwuY00X27gDS9urbids/Azdgby4NTp0yGiBSmQYCM7+Ns20X89MuZAWPU20rBZjL2YNPiReWN9bOhlYY1CnVHDyMaqUbVtGnw02mYYBA3//wLx3zClWXLoFMWQgQRgesSrt/oxpGj32dCgYoprYm9PTmfnaH2n6+tletd5++sW7m8Zl7dTMH4I4n37Dtc/vFnew4QRDa/v6F804YVHgOKGHAUcObMCdi+JIxYsPVhxMIf6ENj4wKsXv8WMhYgDTADYQX5ubWDDh460h2ZHhgWTc7Dlgmke5J92rdWmJmUUnl7TQBEAKWU3rjmdfnm2x9DKY+xft0K6UmK8X0GCUOHFNKeDwbBF4CB7J4IqYyH276F12NBWoOZkQkpSfb2EYhGJJgRAgBAKwVHa1gi6AIE5PCtBYmihY11cmfAIugK9fUxHEcDTNBa3Q/NoTEMAEQErTVYA6T1vWOFR1XkqAImAiKC4ziklIBAKDwd5Efh1VsAImMly4nhiQp4GpQEFJuSgGJTElBsSgKKTdEF5Ibs4+21iy7AMQae78MxZnzPT7A/eSMCOK6Lv9va0f77OcxfvBCOGyj4PUVpARHAMQ7aT/6Ka04Ac3Z9ikt3+3Gp9Tc4xqCQaCpKC5AC2PNATUtQt3MHnMogQvUNaN+0BWBb0Dzw6QtQCpwCZmzdBjcSAQzgdVs41VWo3/0lbG8veACgPCdSxQkhC7i1EQgAyQjI0eBBAbkGpuY5iA/kOwMqzkdMWcdB9MBRRQALhJG380ARs9CoTo5j3ln0fuBxeeYFjAwhImtZxDLEt1xQTs4+roSZAa0JDxKiMLMQaYhwQXHCzGBWwiwgkH34+jABmXSKwLaiMmRIK5BW4/pESCuTzTAiuTEOVVYaMhoQLqzRmRXCBhQuNxDxK7xMelgF5DwUiFB41UcpFerd+V1rb9Mfd64xs6W810YJUFAIh9zBRbMmharDZUvcgFtpPIUyIHn51uCJc5f7e5P96XKAx1ydHiZABEFXy8WOpKLQ1FNl6bJBiBCIhi3uItp8zElsX+rP/eDEylQ6HU/1dkMpnbehoS/LZPyr29YuvnDj9IEX7vb7NPOlNac+bznZ4BozHcgOJQrpbZktyiZNRtA171zY9eLXOV+HCcipmv3e/unhyVPnG06JcP55TcQS4MDCj3g+vdtvg6+4dy90AUCmqiFSToPHjea9pNxOwAdI5103pEREhci7ffXsua9WXxm1BSaaBZuPbrVOxQ4QQaWT285+8donT8LOKAKEYrG4GvM3zxhE50Wo5nyXxOOr7NyNh7aARLXvfnMXYi06Oi9CifNd45q5xADE4zEG6MmvVwIAmkWNWn6WiMVadCzW8tg/DUuUKPE/5l84MhkVkUnJNwAAAABJRU5ErkJggg== // @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 // @connect www.youtube.com // @connect youtube.com // ==/UserScript== (() => { "use strict"; const USERSCRIPT_SETTINGS_KEY = "bilibli-reader-settings"; const runtimeListeners = []; const storageListeners = []; GM_addStyle("#blr-native-transcript-panel {\n --blr-native-transcript-body-height: 439px;\n --blr-native-transcript-font-size: 14px;\n --blr-native-transcript-font-weight: 500;\n --blr-native-transcript-surface: var(--bg1, #fff);\n --blr-native-transcript-text: var(--text1, #18191c);\n --blr-native-transcript-border: var(--line_regular, #e3e5e7);\n --blr-native-transcript-active: rgba(0, 174, 236, 0.12);\n width: 100%;\n min-width: 0;\n position: relative;\n z-index: 20;\n margin: 0 0 12px;\n box-sizing: border-box;\n isolation: isolate;\n pointer-events: auto;\n color: var(--text1, #18191c);\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", sans-serif;\n}\n\n#blr-native-transcript-panel button,\n#blr-native-transcript-panel select,\n#blr-native-transcript-panel .blr-native-transcript-body,\n#blr-native-transcript-panel .blr-native-transcript-list {\n pointer-events: auto;\n}\n\n.blr-native-transcript-header {\n width: 100%;\n height: 44px;\n display: flex;\n align-items: center;\n gap: 4px;\n margin: 0;\n padding: 0 6px 0 10px;\n box-sizing: border-box;\n border-radius: 6px;\n background: var(--bg2, #f1f2f3);\n color: var(--text1, #18191c);\n font: inherit;\n}\n\n.blr-native-transcript-title-button {\n -webkit-appearance: none;\n appearance: none;\n min-width: 32px;\n flex: 0 0 auto;\n align-self: stretch;\n margin: 0;\n padding: 0;\n overflow: hidden;\n border: 0;\n background: transparent;\n color: inherit;\n font: inherit;\n font-size: 15px;\n font-weight: 500;\n line-height: 1;\n text-align: left;\n text-overflow: ellipsis;\n white-space: nowrap;\n cursor: pointer;\n}\n\n.blr-native-transcript-controls {\n min-width: 0;\n display: flex;\n flex: 1 1 auto;\n flex-wrap: nowrap;\n align-items: center;\n gap: 4px;\n}\n\n.blr-native-transcript-reader-button,\n.blr-native-transcript-return-button,\n.blr-native-transcript-theme-button {\n -webkit-appearance: none;\n appearance: none;\n width: 26px;\n height: 26px;\n display: inline-flex;\n flex: 0 0 26px;\n align-items: center;\n justify-content: center;\n margin: 0;\n padding: 0;\n border: 1px solid var(--line_regular, #e3e5e7);\n border-radius: 50%;\n background: var(--bg1, #fff);\n color: var(--text1, #18191c);\n cursor: pointer;\n}\n\n.blr-native-transcript-reader-button svg,\n.blr-native-transcript-return-button svg,\n.blr-native-transcript-theme-button svg {\n width: 17px;\n height: 17px;\n}\n\n.blr-native-transcript-reader-button:hover,\n.blr-native-transcript-return-button:hover,\n.blr-native-transcript-return-button.is-active,\n.blr-native-transcript-theme-button:hover,\n.blr-native-transcript-theme-button.is-active {\n border-color: var(--brand_blue, #00aeec);\n color: var(--brand_blue, #00aeec);\n}\n\n.blr-native-transcript-reader-button:focus-visible {\n outline: 2px solid var(--brand_blue, #00aeec);\n outline-offset: 2px;\n}\n\n.blr-native-transcript-reader-button:disabled {\n opacity: 0.6;\n cursor: wait;\n}\n\n.blr-native-transcript-controls select {\n -webkit-appearance: none;\n appearance: none;\n flex: 0 0 auto;\n height: 28px;\n min-width: 0;\n padding: 0 25px 0 8px;\n box-sizing: border-box;\n border: 1px solid var(--line_regular, #e3e5e7);\n border-radius: 5px;\n background-color: var(--bg1, #fff);\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='m3 4.5 3 3 3-3' fill='none' stroke='%2318191c' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");\n background-position: right 7px center;\n background-repeat: no-repeat;\n background-size: 12px 12px;\n color: var(--text1, #18191c);\n font-size: 12px;\n outline: none;\n}\n\n.blr-native-transcript-controls select:focus {\n border-color: var(--brand_blue, #00aeec);\n}\n\n#blr-native-transcript-select {\n width: auto;\n max-width: 100%;\n flex: 1 1 auto;\n min-width: 0;\n}\n\n#blr-native-transcript-font-size {\n width: 52px;\n}\n\n#blr-native-transcript-font-weight {\n width: 60px;\n}\n\n.blr-native-transcript-arrow-button {\n -webkit-appearance: none;\n appearance: none;\n width: 28px;\n height: 28px;\n display: inline-flex;\n flex: 0 0 28px;\n align-items: center;\n justify-content: center;\n margin: 0;\n padding: 0;\n border: 0;\n border-radius: 4px;\n background: transparent;\n color: inherit;\n cursor: pointer;\n}\n\n.blr-native-transcript-title-button:focus-visible,\n.blr-native-transcript-return-button:focus-visible,\n.blr-native-transcript-theme-button:focus-visible,\n.blr-native-transcript-arrow-button:focus-visible {\n outline: 2px solid var(--brand_blue, #00aeec);\n outline-offset: 1px;\n}\n\n.blr-native-transcript-arrow-button:hover {\n background: rgba(0, 0, 0, 0.06);\n}\n\n.blr-native-transcript-arrow {\n width: 16px;\n height: 16px;\n flex: 0 0 auto;\n fill: none;\n stroke: currentColor;\n stroke-width: 1.6;\n stroke-linecap: round;\n stroke-linejoin: round;\n transform: rotate(90deg);\n transition: transform 0.18s ease;\n}\n\n#blr-native-transcript-panel.is-folded .blr-native-transcript-arrow {\n transform: rotate(0deg);\n}\n\n.blr-native-transcript-empty-notice {\n position: absolute;\n top: calc(100% - 4px);\n left: 50%;\n z-index: 1;\n transform: translateX(-50%);\n padding: 4px 8px;\n border-radius: 8px;\n background: var(--bg1, #fff);\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);\n color: #00aeec;\n font-size: 13px;\n font-weight: 600;\n line-height: 20px;\n white-space: nowrap;\n pointer-events: none;\n animation: blr-native-transcript-notice-fade 2.2s ease-out forwards;\n}\n\n@keyframes blr-native-transcript-notice-fade {\n 0% { opacity: 0; }\n 8%, 65% { opacity: 1; }\n 100% { opacity: 0; }\n}\n\n.blr-native-transcript-body {\n height: var(--blr-native-transcript-body-height);\n min-height: 0;\n display: flex;\n flex-direction: column;\n margin-top: 12px;\n overflow: hidden;\n box-sizing: border-box;\n border: 1px solid var(--blr-native-transcript-border);\n border-radius: 6px;\n background: var(--blr-native-transcript-surface);\n color: var(--blr-native-transcript-text);\n}\n\n#blr-native-transcript-panel[data-theme=\"dark\"] {\n --blr-native-transcript-surface: #0f172a;\n --blr-native-transcript-text: #e5eefb;\n --blr-native-transcript-border: rgba(148, 163, 184, 0.28);\n --blr-native-transcript-active: rgba(0, 174, 236, 0.16);\n}\n\n#blr-native-transcript-panel[data-theme=\"paper\"] {\n --blr-native-transcript-surface: #f4eddc;\n --blr-native-transcript-text: #3b3124;\n --blr-native-transcript-border: rgba(180, 155, 112, 0.34);\n --blr-native-transcript-active: rgba(0, 174, 236, 0.14);\n}\n\n.blr-native-transcript-body[hidden] {\n display: none !important;\n}\n\n.blr-native-transcript-list {\n overflow-anchor: none;\n scroll-behavior: auto;\n min-height: 0;\n flex: 1 1 auto;\n overflow-x: hidden;\n overflow-y: auto;\n overscroll-behavior: contain;\n scrollbar-width: thin;\n scrollbar-color: rgba(148, 153, 160, 0.72) transparent;\n}\n\n.blr-native-transcript-list::-webkit-scrollbar {\n width: 6px;\n}\n\n.blr-native-transcript-list::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.blr-native-transcript-list::-webkit-scrollbar-thumb {\n border-radius: 999px;\n background: rgba(148, 153, 160, 0.72);\n}\n\n.blr-native-transcript-complete {\n padding: 12px 14px 36px;\n color: var(--blr-native-transcript-text);\n font-size: var(--blr-native-transcript-font-size);\n font-weight: var(--blr-native-transcript-font-weight);\n line-height: 1.8;\n letter-spacing: 0;\n text-align: left;\n word-break: break-word;\n}\n\n.blr-native-transcript-segment {\n -webkit-appearance: none;\n appearance: none;\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-native-transcript-segment:hover {\n background: rgba(0, 174, 236, 0.1);\n}\n\n.blr-native-transcript-segment.is-active {\n background: var(--blr-native-transcript-active);\n text-decoration-line: underline;\n text-decoration-color: var(--brand_blue, #00aeec);\n text-decoration-thickness: 2px;\n text-underline-offset: 4px;\n}\n\n.blr-native-transcript-segment:focus-visible {\n outline: 2px solid rgba(0, 174, 236, 0.42);\n outline-offset: 1px;\n}\n\n.blr-native-transcript-state {\n min-height: 100%;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 12px;\n padding: 24px;\n box-sizing: border-box;\n color: var(--text3, #9499a0);\n font-size: 13px;\n text-align: center;\n}\n\n.blr-native-transcript-state button {\n height: 30px;\n padding: 0 14px;\n border: 1px solid var(--brand_blue, #00aeec);\n border-radius: 5px;\n background: transparent;\n color: var(--brand_blue, #00aeec);\n cursor: pointer;\n}\n\n.blr-native-transcript-state button:hover {\n background: rgba(0, 174, 236, 0.08);\n}\n\n/* The YouTube panel sits above recommendations in the secondary column,\n which also handles narrow screens and theater mode without moving the player. */\n#blr-native-transcript-panel[data-platform=\"youtube\"][data-theme=\"light\"] {\n --blr-native-transcript-surface: var(--yt-spec-base-background, #fff);\n --blr-native-transcript-text: var(--yt-spec-text-primary, #0f0f0f);\n --blr-native-transcript-border: var(--yt-spec-10-percent-layer, #e5e5e5);\n color: var(--yt-spec-text-primary, #0f0f0f);\n}\n\n#blr-native-transcript-panel[data-platform=\"youtube\"] .blr-native-transcript-header {\n gap: 4px;\n padding: 0 6px 0 10px;\n background: var(--yt-spec-badge-chip-background, #f2f2f2);\n color: var(--yt-spec-text-primary, #0f0f0f);\n border-radius: 12px;\n}\n\n#blr-native-transcript-panel[data-platform=\"youtube\"] .blr-native-transcript-title-button {\n flex: 0 0 auto;\n}\n\n#blr-native-transcript-panel[data-platform=\"youtube\"] .blr-native-transcript-controls {\n flex: 1 1 auto;\n flex-wrap: nowrap;\n gap: 4px;\n}\n\n#blr-native-transcript-panel[data-platform=\"youtube\"] #blr-native-transcript-select {\n width: auto;\n max-width: 100%;\n flex: 1 1 auto;\n min-width: 0;\n}\n\n#blr-native-transcript-panel[data-platform=\"youtube\"] #blr-native-transcript-font-size {\n width: 52px;\n}\n\n#blr-native-transcript-panel[data-platform=\"youtube\"] #blr-native-transcript-font-weight {\n width: 60px;\n}\n\n#blr-native-transcript-panel[data-platform=\"youtube\"] .blr-native-transcript-body {\n margin-top: 8px;\n border-radius: 12px;\n}\n\n[data-blr-youtube-transcript-open] > ytd-engagement-panel-section-list-renderer[target-id=\"PAmodern_transcript_view\"],\n[data-blr-youtube-transcript-open] > ytd-engagement-panel-section-list-renderer[target-id=\"engagement-panel-searchable-transcript\"] {\n display: none !important;\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/* Move the player and subtitle panel together while the page dissolves. */\nhtml[data-blr-reader-transition]::view-transition {\n pointer-events: none;\n}\n\nhtml[data-blr-reader-transition]::view-transition-image-pair(blr-reader-player),\nhtml[data-blr-reader-transition]::view-transition-image-pair(blr-reader-transcript) {\n isolation: auto;\n}\n\nhtml[data-blr-reader-transition]::view-transition-new(blr-reader-player),\nhtml[data-blr-reader-transition]::view-transition-new(blr-reader-transcript) {\n mix-blend-mode: normal;\n height: 100%;\n object-fit: fill;\n}\n\n/* Both directions share the return movement's duration and easing. */\nhtml[data-blr-reader-transition]::view-transition-group(root) {\n animation: none;\n}\n\nhtml[data-blr-reader-transition]::view-transition-group(blr-reader-transcript) {\n animation-duration: 300ms;\n animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n/* Translate both page templates without stretching either panel's text. The\n fixed group accommodates both sizes while their original layouts crossfade. */\nhtml[data-blr-reader-transcript-transition]::view-transition-group(blr-reader-transcript) {\n width: var(--blr-transcript-width) !important;\n height: var(--blr-transcript-height) !important;\n top: 0 !important;\n left: 0 !important;\n transform-origin: 0 0 !important;\n animation-name: blr-reader-transcript-move !important;\n animation-fill-mode: both !important;\n}\n\nhtml[data-blr-reader-transcript-transition]::view-transition-old(blr-reader-transcript) {\n width: var(--blr-transcript-from-width) !important;\n height: var(--blr-transcript-from-height) !important;\n object-fit: fill;\n mix-blend-mode: normal;\n}\n\nhtml[data-blr-reader-transcript-transition]::view-transition-new(blr-reader-transcript) {\n width: var(--blr-transcript-to-width) !important;\n height: var(--blr-transcript-to-height) !important;\n object-fit: fill;\n}\n\n@keyframes blr-reader-transcript-move {\n from { transform: translate(var(--blr-transcript-from-x), var(--blr-transcript-from-y)); }\n to { transform: translate(var(--blr-transcript-to-x), var(--blr-transcript-to-y)); }\n}\n\n/* Use one transform on a fixed-size bitmap. The default shared-element\n width/height animation can resize the native video surface a second time. */\nhtml[data-blr-reader-transition]::view-transition-group(blr-reader-player) {\n width: var(--blr-transition-player-width) !important;\n height: var(--blr-transition-player-height) !important;\n top: 0 !important;\n left: 0 !important;\n transform-origin: 0 0 !important;\n animation: blr-reader-player-move 300ms cubic-bezier(0.4, 0, 0.2, 1) both !important;\n}\n\nhtml[data-blr-reader-transition]::view-transition-image-pair(blr-reader-player) {\n width: 100%;\n height: 100%;\n animation: none !important;\n}\n\n@keyframes blr-reader-player-move {\n from {\n transform: translate(var(--blr-transition-player-from-x), var(--blr-transition-player-from-y)) scale(1);\n }\n to {\n transform: translate(var(--blr-transition-player-to-x), var(--blr-transition-player-to-y))\n scale(var(--blr-transition-player-scale-x), var(--blr-transition-player-scale-y));\n }\n}\n\nhtml[data-blr-reader-transition]::view-transition-old(root) {\n animation: blr-reader-fade-out 180ms ease-in both;\n}\n\nhtml[data-blr-reader-transition]::view-transition-new(root) {\n animation: blr-reader-fade-in 300ms cubic-bezier(0.4, 0, 0.2, 1) both;\n}\n\n/* Move a stable captured frame. The native player's live surface can resize\n internally during a mode switch and otherwise scale twice in the group. */\nhtml[data-blr-reader-transition]::view-transition-old(blr-reader-player) {\n animation: none !important;\n transform: none !important;\n opacity: 1;\n width: 100%;\n height: 100%;\n object-fit: fill;\n mix-blend-mode: normal;\n}\n\nhtml[data-blr-reader-transition]::view-transition-new(blr-reader-player) {\n animation: none;\n opacity: 0;\n}\n\n/* Native layout changes must finish before the transition captures its target.\n The shared snapshot handles motion; host-page CSS must not add another one. */\nhtml[data-blr-reader-transition] :is(\n .left-container, .scroll-sticky, #playerWrap, .player-wrap, #bilibili-player,\n .bpx-player-container, .bpx-player-video-area, .bpx-player-primary-area, .bpx-player-inner\n) {\n transition: none !important;\n animation: none !important;\n}\n\nhtml[data-blr-reader-transition]::view-transition-old(blr-reader-transcript) {\n animation: blr-reader-fade-out 300ms cubic-bezier(0.4, 0, 0.2, 1) both;\n mix-blend-mode: normal;\n height: 100%;\n object-fit: fill;\n}\n\nhtml[data-blr-reader-transition]::view-transition-new(blr-reader-transcript) {\n animation: blr-reader-fade-in 300ms cubic-bezier(0.4, 0, 0.2, 1) both;\n}\n\n/* Lightweight entrance on browsers without snapshot transitions. */\nhtml[data-blr-reader-entering=\"1\"] #blr-reading-inline-host,\nhtml[data-blr-reader-entering=\"1\"] #blr-reading-view[data-blr-reader-ready=\"1\"] .blr-reading-topbar {\n animation: blr-reader-panel-in 280ms cubic-bezier(0.22, 1, 0.36, 1) both;\n}\n\n@keyframes blr-reader-fade-in {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n\n@keyframes blr-reader-fade-out {\n from { opacity: 1; }\n to { opacity: 0; }\n}\n\n@keyframes blr-reader-panel-in {\n from { opacity: 0; transform: translateY(8px); }\n to { opacity: 1; transform: translateY(0); }\n}\n\n@media (prefers-reduced-motion: reduce) {\n html[data-blr-reader-transition]::view-transition-group(*),\n html[data-blr-reader-transition]::view-transition-old(*),\n html[data-blr-reader-transition]::view-transition-new(*),\n html[data-blr-reader-entering=\"1\"] #blr-reading-inline-host,\n html[data-blr-reader-entering=\"1\"] #blr-reading-view .blr-reading-topbar {\n animation: none !important;\n }\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-topbar,\n.blr-reading-heading-group {\n min-width: 0;\n}\n\n.blr-reading-page-title {\n color: var(--blr-reader-text, #0f172a);\n display: -webkit-box;\n font-size: 18px;\n font-weight: 500;\n line-height: 1.2;\n overflow: hidden;\n overflow-wrap: anywhere;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n}\n\n.blr-reading-episode-title {\n color: var(--blr-reader-muted, #64748b);\n display: -webkit-box;\n font-size: 14px;\n font-weight: 600;\n line-height: 1.3;\n overflow: hidden;\n overflow-wrap: anywhere;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n}\n\n.blr-reading-collection-nav {\n align-items: flex-start;\n display: flex;\n gap: 10px;\n min-width: 0;\n}\n\n.blr-reading-episode-title[hidden],\n.blr-reading-collection-nav[hidden] {\n display: none !important;\n}\n\n.blr-reading-collection-list {\n align-content: flex-start;\n align-items: center;\n display: flex;\n flex: 1 1 auto;\n flex-wrap: wrap;\n gap: 6px;\n height: 70px;\n min-width: 0;\n overflow-x: hidden;\n overflow-y: auto;\n overscroll-behavior-y: contain;\n scrollbar-width: none;\n}\n\n.blr-reading-collection-list::-webkit-scrollbar {\n display: none;\n}\n\n.blr-reading-collection-item {\n align-items: center;\n background: transparent;\n border: 1px solid var(--blr-reader-border, #e2e8f0);\n border-radius: 999px;\n color: var(--blr-reader-muted, #64748b);\n cursor: pointer;\n display: inline-flex;\n flex: 0 0 auto;\n gap: 6px;\n height: 32px;\n max-width: 260px;\n padding: 0 10px 0 7px;\n font-weight: 600;\n}\n\n.blr-reading-collection-item:hover {\n border-color: var(--blr-reader-accent, #00aeec);\n color: var(--blr-reader-text, #0f172a);\n}\n\n.blr-reading-collection-item.is-active {\n background: var(--blr-reader-accent-soft, rgba(0, 174, 236, 0.12));\n border-color: var(--blr-reader-accent, #00aeec);\n color: var(--blr-reader-accent, #00aeec);\n}\n\n.blr-reading-collection-index {\n align-items: center;\n background: color-mix(in srgb, currentColor 10%, transparent);\n border-radius: 999px;\n display: inline-flex;\n flex: 0 0 auto;\n font-size: 10px;\n font-weight: 700;\n height: 18px;\n justify-content: center;\n min-width: 18px;\n padding: 0 4px;\n}\n\n.blr-reading-collection-item-title {\n font-size: 12px;\n font-weight: 600;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\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 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 cursor: pointer;\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-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-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 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 display: grid;\n gap: 4px;\n padding: 8px 10px;\n color: #4b5563;\n}\n\n.blr-reading-chapter:hover {\n background: #f9fafb;\n}\n\n.blr-reading-chapter.is-active {\n background: transparent;\n box-shadow: none;\n}\n\n.blr-reading-chapter-time {\n font-size: var(--blr-reader-transcript-font-size);\n font-weight: 600;\n color: var(--blr-reader-accent, #00aeec);\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 text-decoration-line: underline;\n text-decoration-color: var(--blr-reader-accent, #00aeec);\n text-decoration-thickness: 2px;\n text-underline-offset: 3px;\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}\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-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-border: rgba(15, 23, 42, 0.1);\n --blr-reader-text: #0f172a;\n --blr-reader-muted: #64748b;\n --blr-reader-accent: var(--brand_blue, #00aeec);\n --blr-reader-accent-soft: rgba(0, 174, 236, 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-border: rgba(148, 163, 184, 0.22);\n --blr-reader-text: #e5eefb;\n --blr-reader-muted: #9fb0c8;\n --blr-reader-accent: var(--brand_blue, #00aeec);\n --blr-reader-accent-soft: rgba(0, 174, 236, 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-border: rgba(180, 155, 112, 0.22);\n --blr-reader-text: #3b3124;\n --blr-reader-muted: #756653;\n --blr-reader-accent: var(--brand_blue, #00aeec);\n --blr-reader-accent-soft: rgba(0, 174, 236, 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 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 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 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-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\n#blr-reading-view .blr-reading-chapter:hover {\n background: var(--blr-reader-surface-2);\n}\n\n#blr-reading-view .blr-reading-chapter.is-active {\n background: var(--blr-reader-accent-soft);\n}\n\n#blr-reading-view .blr-reading-chapter-time {\n color: var(--blr-reader-accent);\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-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\n/* The native player stays in normal flow; the transcript uses its own scroll. */\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-topbar {\n display: grid;\n gap: clamp(12px, 1.2vw, 20px);\n grid-template-columns: clamp(300px, 28vw, 520px) minmax(0, 1fr);\n height: 84px;\n left: var(--blr-reader-page-padding);\n min-width: 0;\n overflow: hidden;\n pointer-events: auto;\n position: fixed;\n right: 174px;\n top: 6px;\n z-index: 2147483646;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-heading-group {\n align-content: center;\n display: grid;\n gap: 2px;\n max-height: 84px;\n overflow: hidden;\n pointer-events: none;\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-page-title {\n font-size: clamp(16px, 1.05vw, 19px);\n}\n\nbody[data-blr-reader-mode=\"1\"] .blr-reading-collection-nav {\n align-self: center;\n height: 70px;\n min-width: 0;\n pointer-events: auto;\n position: static;\n}\n\n@media (max-width: 1000px) {\n body[data-blr-reader-mode=\"1\"] .blr-reading-topbar {\n grid-template-columns: clamp(240px, 34vw, 330px) minmax(0, 1fr);\n }\n}\n\n@media (max-width: 760px) {\n body[data-blr-reader-mode=\"1\"] .blr-reading-topbar {\n grid-template-columns: minmax(190px, 42vw) minmax(0, 1fr);\n right: 150px;\n }\n\n body[data-blr-reader-mode=\"1\"] .blr-reading-page-title {\n font-size: 15px;\n }\n\n body[data-blr-reader-mode=\"1\"] .blr-reading-episode-title {\n font-size: 12px;\n }\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-has-chapters=\"0\"] .blr-reading-rail,\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-has-chapters=\"0\"] .blr-reading-rail,\n#blr-reading-view[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 overflow-anchor: none;\n scroll-behavior: auto;\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: 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\"] .bpx-player-video-wrap video {\n width: 100% !important;\n height: 100% !important;\n object-fit: contain !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-chapter-title,\nbody[data-blr-reader-mode=\"1\"] .blr-reading-empty {\n color: var(--blr-reader-text) !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 display: -webkit-box !important;\n margin-top: 8px !important;\n color: transparent !important;\n font-size: clamp(16px, 1.05vw, 20px) !important;\n line-height: 1.2 !important;\n padding: 0 !important;\n height: auto !important;\n max-height: 48px !important;\n overflow: hidden !important;\n overflow-wrap: anywhere !important;\n pointer-events: none !important;\n user-select: none !important;\n visibility: hidden !important;\n -webkit-box-orient: vertical !important;\n -webkit-line-clamp: 2 !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\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: rgba(0, 174, 236, 0.1);\n}\n\n.blr-reading-complete-segment.is-active {\n background: var(--blr-reader-accent-soft);\n box-shadow: 0 0 0 2px var(--blr-reader-accent-soft);\n -webkit-box-decoration-break: clone;\n box-decoration-break: clone;\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 /* Watch Later centers its own player column; align it with the reader's fixed transcript. */\n body[data-blr-reader-mode=\"1\"] #app.playlist-app #mirror-vdcon {\n box-sizing: border-box;\n justify-content: flex-start !important;\n padding-left: var(--blr-reader-page-padding) !important;\n padding-right: var(--blr-reader-page-padding) !important;\n }\n\n body[data-blr-reader-mode=\"1\"] #app.playlist-app .playlist-container--left {\n flex: 0 0 var(--blr-reader-main-width) !important;\n min-width: 0 !important;\n width: var(--blr-reader-main-width) !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\"] 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: 0;\n border-radius: 4px;\n }\n\n body[data-blr-reader-mode=\"1\"] #blr-reading-inline-host {\n position: fixed !important;\n top: var(--blr-reader-player-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 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 justify-content: space-between;\n gap: 6px;\n padding: 0 6px 0 10px;\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\"]\n #blr-reading-inline-host\n .blr-reading-transcript-heading-title {\n min-width: 24px;\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n body[data-blr-reader-mode=\"1\"]\n #blr-reading-inline-host\n .blr-reading-transcript-heading-controls {\n display: flex;\n flex: 0 0 auto;\n align-items: center;\n gap: 4px;\n letter-spacing: 0;\n }\n\n body[data-blr-reader-mode=\"1\"]\n #blr-reading-inline-host\n .blr-reading-transcript-tool-button {\n -webkit-appearance: none;\n appearance: none;\n width: 26px;\n height: 26px;\n display: inline-flex;\n flex: 0 0 26px;\n align-items: center;\n justify-content: center;\n margin: 0;\n padding: 0;\n border: 1px solid var(--blr-reader-border);\n border-radius: 50%;\n background: var(--blr-reader-surface);\n color: var(--blr-reader-text);\n cursor: pointer;\n }\n\n body[data-blr-reader-mode=\"1\"]\n #blr-reading-inline-host\n .blr-reading-transcript-tool-button svg {\n width: 17px;\n height: 17px;\n }\n\n body[data-blr-reader-mode=\"1\"]\n #blr-reading-inline-host\n .blr-reading-transcript-tool-button:hover,\n body[data-blr-reader-mode=\"1\"]\n #blr-reading-inline-host\n .blr-reading-transcript-tool-button.is-active {\n border-color: var(--blr-reader-accent);\n color: var(--blr-reader-accent);\n }\n\n body[data-blr-reader-mode=\"1\"]\n #blr-reading-inline-host\n .blr-reading-transcript-heading-controls\n select {\n -webkit-appearance: none;\n appearance: none;\n height: 26px;\n flex: 0 0 auto;\n padding: 0 23px 0 7px;\n box-sizing: border-box;\n border: 1px solid var(--blr-reader-border);\n border-radius: 5px;\n background-color: var(--blr-reader-surface);\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='m3 4.5 3 3 3-3' fill='none' stroke='%2364748b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");\n background-position: right 6px center;\n background-repeat: no-repeat;\n background-size: 12px 12px;\n color: var(--blr-reader-text);\n font-size: 12px;\n font-weight: 500;\n line-height: 1;\n outline: none;\n }\n\n body[data-blr-reader-mode=\"1\"]\n #blr-reading-inline-host\n .blr-reading-transcript-heading-controls\n select:focus {\n border-color: var(--blr-reader-accent);\n }\n\n #blr-reading-transcript-language {\n width: 72px;\n }\n\n #blr-reading-transcript-font-size {\n width: 56px;\n }\n\n #blr-reading-transcript-font-weight {\n width: 64px;\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-tail-spacer {\n min-height: 45vh;\n }\n\n body[data-blr-reader-mode=\"1\"] .blr-reading-resize-handle {\n position: fixed;\n top: var(--blr-reader-player-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-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}\n\n@media (max-width: 1180px) {\n .blr-reading-resize-handle {\n display: none !important;\n }\n}\n\n/* YouTube reader: keep the native player mounted and let it own its controls. */\nhtml[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"],\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] {\n overflow: hidden !important;\n}\n\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] :is(\n ytd-app, #content.ytd-app, ytd-page-manager, ytd-watch-flexy,\n #full-bleed-container.ytd-watch-flexy, #player-full-bleed-container.ytd-watch-flexy,\n #columns.ytd-watch-flexy,\n #primary.ytd-watch-flexy, #primary-inner.ytd-watch-flexy,\n #player.ytd-watch-flexy, #player-container-outer.ytd-watch-flexy,\n #player-container-inner.ytd-watch-flexy, #player-container.ytd-watch-flexy,\n ytd-player, #container.ytd-player\n) {\n transform: none !important;\n contain: none !important;\n overflow: visible !important;\n /* Keep the docking hosts visible without painting YouTube's theater\n backdrop across the reader. The video keeps its own player background. */\n background: transparent !important;\n box-shadow: none !important;\n}\n\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] :is(\n #full-bleed-container.ytd-watch-flexy, #player-full-bleed-container.ytd-watch-flexy\n)::before,\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] :is(\n #full-bleed-container.ytd-watch-flexy, #player-full-bleed-container.ytd-watch-flexy\n)::after {\n background: transparent !important;\n box-shadow: none !important;\n}\n\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] #cinematics {\n display: none !important;\n}\n\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] #movie_player:not(.ytp-fullscreen):not(:fullscreen):not(:fullscreen *) {\n position: fixed !important;\n inset: auto !important;\n top: var(--blr-reader-player-top, 98px) !important;\n left: var(--blr-reader-player-left, var(--blr-reader-page-padding)) !important;\n width: var(--blr-reader-player-rendered-width) !important;\n height: var(--blr-reader-player-rendered-height) !important;\n max-width: none !important;\n max-height: none !important;\n margin: 0 !important;\n transform: none !important;\n z-index: 100 !important;\n}\n\n/* Fullscreen can belong to the player itself or one of YouTube's wrappers.\n Fill that viewport instead of falling back to stale native inline sizes,\n and paint letterboxing black rather than exposing the reader page. */\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] :is(\n #movie_player.ytp-fullscreen, #movie_player:fullscreen, :fullscreen #movie_player\n) {\n position: fixed !important;\n inset: 0 !important;\n width: 100% !important;\n height: 100% !important;\n max-width: none !important;\n max-height: none !important;\n margin: 0 !important;\n transform: none !important;\n background: #000 !important;\n z-index: 100 !important;\n}\n\n/* A fullscreen docking host overrides its transparent theater background. */\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] :fullscreen:has(#movie_player),\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] :fullscreen:has(#movie_player)::backdrop,\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] #movie_player:fullscreen::backdrop {\n background: #000 !important;\n}\n\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"]:has(\n #movie_player.ytp-fullscreen, #movie_player:fullscreen, :fullscreen #movie_player\n) :is(#blr-root, #blr-reading-inline-host) {\n display: none !important;\n}\n\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] #movie_player :is(.html5-video-container, video) {\n width: 100% !important;\n height: 100% !important;\n top: 0 !important;\n left: 0 !important;\n object-fit: contain !important;\n}\n\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] #movie_player .ytp-chrome-bottom {\n width: calc(100% - 24px) !important;\n left: 12px !important;\n}\n\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] .blr-reading-topbar {\n grid-template-columns: minmax(0, 1fr);\n right: 76px;\n}\n\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] .blr-reading-actions {\n margin: 0;\n opacity: 1;\n visibility: visible;\n pointer-events: auto;\n}\n\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] #blr-reading-view[data-blr-reader-ready=\"0\"] {\n opacity: 1;\n visibility: visible;\n}\n\n/* Keep the title and all controls on one line, including the 280px column. */\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host .blr-reading-transcript-heading {\n flex-wrap: nowrap;\n gap: 4px;\n padding-left: 4px;\n padding-right: 4px;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host .blr-reading-transcript-heading-title {\n flex: 0 0 auto;\n min-width: 0;\n overflow: visible;\n white-space: nowrap;\n letter-spacing: 0;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host .blr-reading-transcript-heading-controls {\n display: flex;\n flex: 1 1 0;\n flex-wrap: nowrap;\n min-width: 0;\n gap: 2px;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host .blr-reading-transcript-tool-button {\n flex: 0 0 26px;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host .blr-reading-transcript-tool-button:disabled {\n opacity: 0.6;\n cursor: wait;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host .blr-reading-transcript-heading-controls select {\n padding-left: 5px;\n padding-right: 20px;\n background-position: right 5px center;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host #blr-reading-transcript-language {\n flex: 1 1 72px;\n width: 72px;\n min-width: 0;\n max-width: none;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host #blr-reading-transcript-font-size {\n flex: 0 0 46px;\n width: 46px;\n min-width: 0;\n}\n\nbody[data-blr-reader-mode=\"1\"] #blr-reading-inline-host #blr-reading-transcript-font-weight {\n flex: 0 0 52px;\n width: 52px;\n min-width: 0;\n}\n\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"][data-blr-reader-has-chapters=\"1\"] .blr-reading-rail {\n display: grid !important;\n top: calc(var(--blr-reader-player-bottom) + 12px) !important;\n left: var(--blr-reader-player-left) !important;\n bottom: auto !important;\n width: var(--blr-reader-player-width) !important;\n height: 112px !important;\n min-height: 0;\n max-height: none !important;\n grid-template-rows: auto minmax(0, 1fr);\n gap: 6px;\n padding: 10px 12px;\n overflow: hidden;\n}\n\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] .blr-reading-rail .blr-reading-list {\n display: flex;\n align-items: stretch;\n gap: 8px;\n min-width: 0;\n overflow-x: auto;\n overflow-y: hidden;\n}\n\nbody[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] .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@media (max-width: 1180px) {\n body[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] #blr-reading-inline-host {\n position: fixed !important;\n top: calc(var(--blr-reader-player-bottom, 40vh) + 16px) !important;\n right: var(--blr-reader-page-padding) !important;\n bottom: 16px !important;\n left: var(--blr-reader-page-padding) !important;\n width: auto !important;\n max-width: none !important;\n min-height: 0 !important;\n height: auto !important;\n margin: 0 !important;\n border: 1px solid var(--blr-reader-border);\n border-radius: 12px;\n scroll-padding-top: 46px;\n z-index: 2147483645;\n }\n\n body[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"][data-blr-reader-has-chapters=\"1\"] #blr-reading-inline-host {\n top: calc(var(--blr-reader-player-bottom) + 144px) !important;\n }\n\n body[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] #blr-reading-inline-host .blr-reading-transcript {\n height: auto;\n overflow: visible;\n }\n\n body[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] .blr-reading-transcript-heading {\n position: sticky;\n top: 0;\n display: flex;\n align-items: center;\n gap: 8px;\n min-height: 44px;\n padding: 6px 10px;\n box-sizing: border-box;\n border-bottom: 1px solid var(--blr-reader-border);\n background: var(--blr-reader-surface-2);\n color: var(--blr-reader-text);\n font-size: 12px;\n font-weight: 700;\n z-index: 3;\n }\n\n body[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] .blr-reading-transcript-heading-controls {\n display: flex;\n flex: 1 1 auto;\n flex-wrap: nowrap;\n align-items: center;\n justify-content: flex-end;\n gap: 4px;\n min-width: 0;\n }\n\n body[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] .blr-reading-transcript-tool-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n padding: 0;\n border: 1px solid var(--blr-reader-border);\n border-radius: 50%;\n background: var(--blr-reader-surface);\n color: var(--blr-reader-text);\n cursor: pointer;\n }\n\n body[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] .blr-reading-transcript-tool-button svg {\n width: 17px;\n height: 17px;\n }\n\n body[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] .blr-reading-transcript-heading-controls select {\n height: 26px;\n max-width: 130px;\n border: 1px solid var(--blr-reader-border);\n border-radius: 5px;\n background: var(--blr-reader-surface);\n color: var(--blr-reader-text);\n font-size: 12px;\n }\n\n body[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] .blr-reading-transcript-tool-button:hover,\n body[data-blr-reader-mode=\"1\"][data-blr-reader-platform=\"youtube\"] .blr-reading-transcript-tool-button:focus-visible {\n border-color: var(--blr-reader-accent);\n color: var(--blr-reader-accent);\n }\n}\n"); const chrome = { runtime: { onMessage: { addListener(listener) { runtimeListeners.push(listener); } }, 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); } }, 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" || message?.type === "fetch-text") { return { ok: true, data: await requestResource(String(message.url || ""), message.type === "fetch-text") }; } return { ok: false, error: "此功能在独立阅读脚本中不可用" }; } async function requestResource(url, asText = false) { if (!url) throw new Error("缺少请求地址"); const normalizedUrl = url.startsWith("//") ? `https:${url}` : url; const parsedUrl = new URL(normalizedUrl); const isYouTubeSubtitle = asText && /(^|\.)youtube\.com$/.test(parsedUrl.hostname) && parsedUrl.pathname === "/api/timedtext"; const deadline = Date.now() + (isYouTubeSubtitle ? 3500 : 20000); const controller = new AbortController(); let timeout = 0; let directError = null; try { const pageFetch = typeof unsafeWindow !== "undefined" && typeof unsafeWindow.fetch === "function" ? unsafeWindow.fetch.bind(unsafeWindow) : fetch; return await Promise.race([ (async () => { const response = await pageFetch(normalizedUrl, { method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json, text/plain, */*" }, signal: controller.signal }); if (!response.ok) throw new Error(`页面请求 HTTP ${response.status}`); return asText ? response.text() : response.json(); })(), new Promise((_, reject) => { timeout = window.setTimeout(() => { reject(new Error("页面请求超时")); controller.abort(); }, isYouTubeSubtitle ? 1500 : 5000); }) ]); } catch (error) { directError = error; } finally { window.clearTimeout(timeout); } return requestJsonWithUserscriptApi(normalizedUrl, directError, Math.max(1, deadline - Date.now()), asText); } function requestJsonWithUserscriptApi(url, directError, timeoutMs, asText = false) { return new Promise((resolve, reject) => { let settled = false; let timeout = 0; let request = null; const finish = (handler, value) => { if (settled) return; settled = true; window.clearTimeout(timeout); handler(value); }; const onload = (response) => { if (response.status < 200 || response.status >= 300) { finish(reject, new Error(`HTTP ${response.status}`)); return; } if (asText) { finish(resolve, response.responseText || response.response || ""); 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: /(^|\.)youtube\.com$/.test(new URL(url).hostname) ? "https://www.youtube.com/" : "https://www.bilibili.com/" }, responseType: "text", anonymous: false, withCredentials: true, timeout: timeoutMs, 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 { timeout = window.setTimeout(() => { finish(reject, new Error("请求超时")); try { request?.abort?.(); } catch {} }, timeoutMs); 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: "阅读脚本尚未就绪" }); }); } const isYouTube = ["www.youtube.com", "youtube.com"].includes(location.hostname); GM_registerMenuCommand(isYouTube ? "展开 YouTube 字幕" : "进入阅读模式", () => { const readerUrl = new URL(location.href); readerUrl.searchParams.set("bilibli_reader", "1"); dispatchRuntimeMessage({ type: "popup-trigger-reading-view", readerUrl: readerUrl.toString() }); }); GM_registerMenuCommand(isYouTube ? "收起 YouTube 字幕" : "退出阅读模式", () => { if (isYouTube) { const panel = document.getElementById("blr-native-transcript-panel"); if (panel && !panel.classList.contains("is-folded")) panel.querySelector("[data-native-transcript-toggle]")?.click(); return; } 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 = { enableDebugLogs: false, readerTheme: "light", readerFontScale: "xl", readerFontWeight: "bold", readerLetterSpacing: "loose", readerLineHeight: "loose", readerContentWidth: "medium", readerChapterWidthPx: 220, readerTranscriptWidthPx: 440, nativeTranscriptTheme: "light", nativeTranscriptFontSize: 14, nativeTranscriptFontWeight: 500, readerDefaultsVersion: 3 }; const READER_VERSION = "0.0.10"; const CACHE_KEY_PREFIX = "bilibli_reader_subtitle_cache_"; globalThis.__BILIBLI_READER_CONTENT_SCRIPT_LOADED__ = READER_VERSION; // Data and request identity for the current video. const clipState = { fetchRunId: 0, fetchClipSignature: "", bvid: "", aid: "", cid: "", cidSource: "", pageIndex: 1, pageCount: 0, pageTitle: "", collection: null, videoDuration: 0, title: "", author: "", uploadDate: "", subtitles: [], selectedSubtitleId: "", selectedSubtitleUrl: "", selectedSubtitleLang: "", subtitleBody: [], subtitleRevision: 0, subtitleFetchState: "idle", chapters: [], currentClipSignature: "" }; // Transient reader lifecycle, playback following, and layout scheduling. const readerSessionState = { open: false, id: 0, mountTask: null, layoutFrame: 0, layoutDirty: false, resizeObserver: null, observedPlayer: null, resizeCleanup: null, transition: null, closing: false, transcriptAutoWidth: false, activeSubtitleIndex: -1, activeChapterIndex: -1, nextScrollBehavior: "smooth", syncTimer: 0, playerMountTimer: 0, playerRetryTimer: 0, manualScrollPauseUntil: 0, programmaticScrollUntil: 0, collectionSwitchInFlight: false, ready: false }; // Persisted settings and their normalized presentation values. const readerPreferences = { theme: "light", fontScale: "m", fontWeight: "normal", letterSpacing: "normal", lineHeight: "tight", contentWidth: "medium", chapterWidthPx: 220, transcriptWidthPx: 440, nativeTheme: "light", nativeFontSize: 14, nativeFontWeight: 500, settings: { ...DEFAULT_SETTINGS } }; // Native player references and compatibility resources owned by the reader. const readerPlayerState = { videoEl: null, host: null, adjustedNodes: [], observer: null, miniDismissTimer: 0, controlsHideTimer: 0, controlsRecoveryTimer: 0, controlsRecoveryInFlight: false, controlsLastRecoverAt: 0, controlsHoverHost: null, videoEventsBound: false, layoutBound: false }; // Normal-page transcript panel lifecycle and playback following. const nativeTranscriptState = { observer: null, syncTimer: 0, resizeObserver: null, observedPlayer: null, playbackTimer: 0, loadPromise: null, loadSignature: "", loadedSignature: "", displaySignature: "", open: true, activeIndex: -1, manualScrollPauseUntil: 0, programmaticScrollUntil: 0 }; // Page-level bindings, entry controls, and the reader DOM presentation. const uiState = { mainOriginalParent: null, mainOriginalNextSibling: null, headerHoverHost: null, headerHideTimer: 0, uiEventsBound: false, runtimeEventsBound: false, settingsWatcherBound: false, normalPageStateGuardBound: false, urlWatcherStarted: false, collectionSnapshot: null }; 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) { if (isYouTubePage(url) && !extractYouTubeVideoId(url)) return false; 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); clipState.currentClipSignature = computeCurrentClipSignature(location.href); refreshReaderPageScopes(); } 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 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 normalizeNativeTranscriptFontSize(value) { const size = Number(value); return [12, 14, 16, 18, 20, 22].includes(size) ? size : 14; } function normalizeNativeTranscriptFontWeight(value) { const weight = Number(value); return [300, 400, 500, 600, 700].includes(weight) ? weight : 500; } 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 shouldDebugLog() { return Boolean(readerPreferences.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", readingView: "blr-reading-view", readingPageTitle: "blr-reading-page-title", readingEpisodeTitle: "blr-reading-episode-title", readingCollectionNav: "blr-reading-collection-nav", readingCollectionList: "blr-reading-collection-list", readingStatus: "blr-reading-status", readingCloseBtn: "blr-reading-close-btn", readingTranscriptResizeHandle: "blr-reading-transcript-resize-handle", readingMeta: "blr-reading-meta", readingChapterList: "blr-reading-chapters", readingTranscriptList: "blr-reading-transcript", readingTranscriptTailSpacer: "blr-reading-tail-spacer", readingTranscriptReaderButton: "blr-reading-transcript-reader", readingTranscriptReturnButton: "blr-reading-transcript-return", readingTranscriptThemeButton: "blr-reading-transcript-theme", readingTranscriptQuickSubtitleSelect: "blr-reading-transcript-language", readingTranscriptQuickFontSizeSelect: "blr-reading-transcript-font-size", readingTranscriptQuickFontWeightSelect: "blr-reading-transcript-font-weight", nativeTranscriptPanel: "blr-native-transcript-panel", nativeTranscriptHeader: "blr-native-transcript-header", nativeTranscriptBody: "blr-native-transcript-body", nativeTranscriptControls: "blr-native-transcript-controls", nativeTranscriptReaderButton: "blr-native-transcript-reader", nativeTranscriptReturnButton: "blr-native-transcript-return", nativeTranscriptThemeButton: "blr-native-transcript-theme", nativeTranscriptSelect: "blr-native-transcript-select", nativeTranscriptFontSizeSelect: "blr-native-transcript-font-size", nativeTranscriptFontWeightSelect: "blr-native-transcript-font-weight", nativeTranscriptList: "blr-native-transcript-list" }; function init() { // document-idle 不代表 B 站已完成异步挂载;提前插入节点会破坏 Vue 的 SSR hydration。 // 等待服务端渲染标记移除后再初始化,也兼容没有 SSR 标记的稍后再看页面。 if ( document.readyState === "loading" || !document.body || document.querySelector( '#app[data-server-rendered="true"], #video-page-app[data-server-rendered="true"]' ) ) { window.setTimeout(init, 50); return; } // A full reload starts in the native view. Preserve other query parameters, // the hash and history state, without navigating or restoring a reader session. if (isReaderMode() && performance.getEntriesByType("navigation")[0]?.type === "reload") { history.replaceState(history.state, "", stripReaderModeUrl()); } 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 { clearReaderPresentationAttributes(); } bindRuntimeEvents(); bindSettingsWatcher(); bindNormalPageStateGuard(); startNativeTranscriptPanelObserver(); startUrlWatcher(); getSettings().then((settings) => { readerPreferences.settings = settings; hydrateReaderStateFromSettings(settings); hydrateNativeTranscriptSettings(settings); applyReadingViewPresentation(); scheduleNativeTranscriptPanelSync(0); if (shouldEnterReaderMode) { enterReaderMode().catch((error) => { renderReadingStatus(`阅读视图启动失败:${getErrorMessage(error)}`); }); } }); } function ensureUiReady({ forceRecreate = false } = {}) { const existingRoot = document.getElementById(ids.root); if (existingRoot && forceRecreate) { existingRoot.remove(); uiState.uiEventsBound = false; } let root = document.getElementById(ids.root); if (!root) { root = document.createElement("div"); root.id = ids.root; root.innerHTML = readerHtml(buildUiHtml()); document.body.appendChild(root); uiState.uiEventsBound = false; } if (!uiState.uiEventsBound) { bindUiEvents(); uiState.uiEventsBound = true; } } function clearReaderPresentationAttributes() { const attributes = [ "mode", "platform", "theme", "font-scale", "font-weight", "letter-spacing", "line-height", "content-width", "has-chapters", "resizing" ]; [document.documentElement, document.body].forEach((node) => { attributes.forEach((name) => node.removeAttribute(`data-blr-reader-${name}`)); }); document.body.removeAttribute("data-blr-reading-active"); } function onTranscriptReaderEntryClick(event) { event.preventDefault(); event.stopPropagation(); if (readerSessionState.open) { return; } const button = event.currentTarget; button.disabled = true; button.classList.add("is-loading"); ensureUiReady(); enterReaderMode({ animate: true }).catch((error) => { if (readerSessionState.open) { readerSessionState.transition?.cancel(); replaceReaderModeUrl(stripReaderModeUrl()); closeReadingView(); } button.disabled = false; button.classList.remove("is-loading"); logWarn("[Bilibili Reader] transcript entry failed", error); renderReadingStatus(`阅读视图启动失败:${getErrorMessage(error)}`); }); } // YouTube uses its native player and shares transcript data with the reader. function isYouTubePage(url = location.href) { try { return ["www.youtube.com", "youtube.com"].includes(new URL(url).hostname); } catch { return false; } } function extractYouTubeVideoId(url = location.href) { try { const parsed = new URL(url); const id = parsed.searchParams.get("v") || ""; return isYouTubePage(url) && parsed.pathname === "/watch" && /^[\w-]{11}$/.test(id) ? id : ""; } catch { return ""; } } function isSupportedTranscriptPage(url = location.href) { return Boolean(extractYouTubeVideoId(url) || (!isYouTubePage(url) && extractBvid(url))); } function getYouTubePageWindow() { return typeof unsafeWindow !== "undefined" ? unsafeWindow : window; } function readYouTubePlayerResponse(videoId) { const page = getYouTubePageWindow(); const player = page.document.getElementById("movie_player"); const watch = page.document.querySelector("ytd-watch-flexy"); const candidates = []; try { candidates.push(player?.getPlayerResponse?.()); } catch {} candidates.push(watch?.playerData, page.ytInitialPlayerResponse); const matching = candidates.filter((item) => item?.videoDetails?.videoId === videoId); // A transient player response must not mask the complete watch-page data. return matching.find((item) => item.captions?.playerCaptionsTracklistRenderer?.captionTracks?.length) || matching.find((item) => item.playabilityStatus?.status === "OK") || matching[0] || null; } function youtubeText(value) { if (typeof value === "string") return value; return value?.simpleText || value?.content || (value?.runs || []).map((item) => item.text || "").join(""); } function mapYouTubeCaptionTracks(response, videoId) { const list = response?.captions?.playerCaptionsTracklistRenderer; const tracks = list?.captionTracks || []; const defaultIndex = list?.audioTracks?.[list.defaultAudioTrackIndex || 0]?.defaultCaptionTrackIndex ?? 0; return tracks.flatMap((track, index) => { if (track.kind === "asr" || String(track.vssId || "").startsWith("a.")) return []; try { const url = new URL(track.baseUrl); if (url.protocol !== "https:" || !isYouTubePage(url.origin) || url.pathname !== "/api/timedtext" || url.searchParams.get("v") !== videoId) return []; url.searchParams.set("fmt", "json3"); const label = youtubeText(track.name) || track.languageCode || "字幕"; return [{ id: `youtube:${videoId}:${track.vssId || `${track.languageCode}:${index}`}`, lan: track.languageCode || "und", lanDoc: label, subtitleUrl: url.toString(), nativeDefault: index === defaultIndex, nativeLabel: youtubeText(track.name), nativeTrackCount: tracks.length, source: "youtube" }]; } catch { return []; } }); } function parseYouTubeChapterTimestamp(value) { const text = String(value || "").trim(); if (!/^\d{1,3}:\d{2}(?::\d{2})?$/.test(text)) return NaN; const parts = text.split(":").map(Number); if (parts.at(-1) >= 60 || (parts.length === 3 && parts[1] >= 60)) return NaN; return parts.reduce((seconds, part) => seconds * 60 + part, 0); } function normalizeYouTubeChapters(chapters, duration = 0) { const sorted = chapters.filter((item) => item.title && Number.isFinite(item.from) && item.from >= 0 && (!duration || item.from < duration)).sort((a, b) => a.from - b.from); const unique = sorted.filter((item, index) => index === 0 || item.from !== sorted[index - 1].from); return unique.map((item, index) => ({ title: item.title.trim(), from: item.from, to: unique[index + 1]?.from ?? (duration > item.from ? duration : 0), source: "youtube" })); } function readYouTubeChapters(videoId, response = readYouTubePlayerResponse(videoId)) { if (!videoId || videoId !== extractYouTubeVideoId()) return []; const page = getYouTubePageWindow(); const watch = page.document.querySelector("ytd-watch-flexy"); const duration = Number(response?.videoDetails?.lengthSeconds) || clipState.videoDuration; const roots = [response]; if (watch?.getAttribute("video-id") === videoId) roots.push(watch.data); // Initial data can belong to the first video after a YouTube SPA navigation. if (page.ytInitialPlayerResponse?.videoDetails?.videoId === videoId) roots.push(page.ytInitialData); const chapters = []; const markers = []; roots.forEach((root) => walkYouTubeData(root, (item) => { const chapter = item.chapterRenderer; if (chapter?.timeRangeStartMillis !== undefined) chapters.push({ title: youtubeText(chapter.title), from: Number(chapter.timeRangeStartMillis) / 1000 }); const marker = item.macroMarkersListItemRenderer; if (!marker) return; const endpoint = marker.onTap?.watchEndpoint || marker.onTap?.innertubeCommand?.watchEndpoint; if (endpoint?.videoId && endpoint.videoId !== videoId) return; markers.push({ title: youtubeText(marker.title), from: endpoint?.startTimeSeconds !== undefined ? Number(endpoint.startTimeSeconds) : parseYouTubeChapterTimestamp(youtubeText(marker.timeDescription)) }); })); const structuredChapters = normalizeYouTubeChapters(chapters, duration); const structured = structuredChapters.length ? structuredChapters : normalizeYouTubeChapters(markers, duration); if (structured.length) return structured; // Chapter cards may arrive after player metadata. Read their native titles // and timestamps without opening the chapter panel or changing playback. const domChapters = []; if (watch?.getAttribute("video-id") === videoId) { watch.querySelectorAll("ytd-macro-markers-list-item-renderer").forEach((node) => { const link = node.querySelector("a[href]"); try { const url = new URL(link?.getAttribute("href") || "", location.origin); if (extractYouTubeVideoId(url.toString()) !== videoId) return; } catch { return; } domChapters.push({ title: node.querySelector("h3:not([hidden]), h3")?.textContent?.trim() || "", from: parseYouTubeChapterTimestamp(node.querySelector("#time")?.textContent) }); }); } const nativeChapters = normalizeYouTubeChapters(domChapters, duration); if (nativeChapters.length) return nativeChapters; // Fall back to the creator's timestamp list when native cards are not mounted. const descriptionChapters = String(response?.videoDetails?.shortDescription || "") .split(/\r?\n/).flatMap((line) => { const match = line.match(/^\s*(\d{1,3}:\d{2}(?::\d{2})?)\s+(.+?)\s*$/); return match ? [{ from: parseYouTubeChapterTimestamp(match[1]), title: match[2] }] : []; }); const isChapterList = descriptionChapters.length >= 3 && descriptionChapters[0].from === 0 && descriptionChapters.every((item, index) => Number.isFinite(item.from) && (index === 0 || item.from > descriptionChapters[index - 1].from)); return isChapterList ? normalizeYouTubeChapters(descriptionChapters, duration) : []; } function syncYouTubeReadingChapters() { if (!isYouTubePage() || !readerSessionState.open || clipState.fetchClipSignature !== computeCurrentClipSignature()) return; const chapters = readYouTubeChapters(extractYouTubeVideoId()); // Do not erase a valid list while YouTube replaces its watch-page nodes. if (!chapters.length || (chapters.length === clipState.chapters.length && chapters.every((item, index) => item.title === clipState.chapters[index].title && item.from === clipState.chapters[index].from && item.to === clipState.chapters[index].to))) return; clipState.chapters = chapters; renderReadingView(); scheduleReaderLayout(); } async function refreshYouTubeClipData(videoId, request) { if (!videoId) throw new Error("请在 YouTube 视频播放页使用字幕功能。"); let response = null; const deadline = Date.now() + 6000; do { ensureSubtitleRequestActive(request); response = readYouTubePlayerResponse(videoId); if (response) break; await sleep(150); } while (Date.now() < deadline); ensureSubtitleRequestActive(request); if (!response) throw new Error("YouTube 播放器尚未就绪,请重试。"); const details = response.videoDetails; clipState.bvid = `youtube:${videoId}`; clipState.cid = videoId; clipState.cidSource = "youtube-player"; clipState.title = details.title || "YouTube 字幕"; clipState.author = details.author || ""; clipState.videoDuration = Number(details.lengthSeconds) || readRuntimeVideoDuration(); clipState.pageCount = 1; clipState.pageIndex = 1; clipState.collection = null; clipState.chapters = readYouTubeChapters(videoId, response); clipState.subtitles = normalizeSubtitleTracks(mapYouTubeCaptionTracks(response, videoId)); const preferred = pickPreferredSubtitle(clipState.subtitles); if (!preferred) { if (response.captions?.playerCaptionsTracklistRenderer?.captionTracks?.some((track) => track.kind !== "asr" && !String(track.vssId || "").startsWith("a."))) { throw new Error("YouTube 字幕轨道暂时无法读取,请重试。"); } await finishNoSubtitleLoad(request); return; } await tryLoadSubtitleCandidates(buildSubtitleCandidates(clipState.subtitles, preferred), request.runId, true, request); ensureSubtitleRequestActive(request); } function normalizeYouTubeCues(cues, duration = 0) { const sorted = cues.filter((cue) => Number.isFinite(cue.from) && cue.from >= 0 && cue.content?.trim()) .sort((a, b) => a.from - b.from); return sorted.map((cue, index) => { const next = sorted[index + 1]?.from; const end = Number.isFinite(cue.to) && cue.to > cue.from ? cue.to : next ?? (duration > cue.from ? duration : cue.from + 5); return { from: cue.from, to: next > cue.from ? Math.min(end, next) : end, content: cue.content.trim() }; }); } function parseYouTubeTimedText(text, duration = 0) { if (!String(text || "").trim()) return []; if (String(text).trimStart().startsWith("{")) { const data = JSON.parse(text); if (!Array.isArray(data.events)) throw new Error("YouTube 字幕格式无效。"); const cues = []; for (const event of data.events) { if (!Array.isArray(event.segs) || event.tStartMs === undefined) continue; const content = event.segs.map((segment) => segment.utf8 || "").join(""); const from = Number(event.tStartMs) / 1000; const to = from + Number(event.dDurationMs || 0) / 1000; if (event.aAppend && cues.length) { cues[cues.length - 1].content += content; cues[cues.length - 1].to = Math.max(cues[cues.length - 1].to, to); } else cues.push({ from, to, content }); } return normalizeYouTubeCues(cues, duration); } const xml = new DOMParser().parseFromString(readerHtml(text), "text/xml"); if (xml.querySelector("parsererror") || !["transcript", "timedtext"].includes(xml.documentElement.tagName)) { throw new Error("YouTube 字幕格式无效。"); } const cues = [...xml.querySelectorAll("text, body > p")].map((node) => { const milliseconds = node.tagName === "p"; const scale = milliseconds ? 1000 : 1; const from = Number(node.getAttribute(milliseconds ? "t" : "start")) / scale; return { from, to: from + Number(node.getAttribute(milliseconds ? "d" : "dur")) / scale, content: node.textContent }; }); return normalizeYouTubeCues(cues, duration); } // Both the older transcript renderer and the modern inline-timestamp view // are delivered by YouTube's own engagement panel loader. function walkYouTubeData(root, visit) { const seen = new Set(); const walk = (item, depth) => { if (!item || typeof item !== "object" || depth > 35 || seen.has(item)) return; seen.add(item); visit(item); Object.values(item).forEach((value) => walk(value, depth + 1)); }; walk(root, 0); } function parseYouTubeTranscriptData(data, duration = 0) { const cues = []; walkYouTubeData(data, (item) => { const legacy = item.transcriptSegmentRenderer; if (legacy?.startMs !== undefined) cues.push({ from: Number(legacy.startMs) / 1000, to: legacy.endMs === undefined ? NaN : Number(legacy.endMs) / 1000, content: youtubeText(legacy.snippet) }); const modern = item.transcriptSegmentViewModel; if (modern && /^\d+(?::\d{1,2}){1,2}$/.test(modern.timestamp || "")) cues.push({ from: modern.timestamp.split(":").reduce((seconds, part) => seconds * 60 + Number(part), 0), to: NaN, content: youtubeText(modern.simpleText) }); const cue = item.transcriptCueRenderer; if (cue?.startOffsetMs !== undefined) cues.push({ from: Number(cue.startOffsetMs) / 1000, to: (Number(cue.startOffsetMs) + Number(cue.durationMs || 0)) / 1000, content: youtubeText(cue.cue) }); }); return normalizeYouTubeCues(cues, duration); } function isYouTubeNativeTranscriptLanguageMatch(data, track, requestedLanguage = false) { let matches = false; let hasSelection = false; walkYouTubeData(data, (item) => { if ((item.selected === true || item.isSelected === true) && (item.title || item.languageCode)) { hasSelection = true; if (youtubeText(item.title) === track.nativeLabel || item.languageCode === track.lan) matches = true; } }); return hasSelection ? matches : requestedLanguage || (track.nativeTrackCount === 1 && track.nativeDefault); } function isYouTubeNativeTranscriptForVideo(data, videoId) { const owners = new Set(); walkYouTubeData(data, (item) => { const id = item.transcriptSegmentRenderer?.targetId; const match = typeof id === "string" ? id.match(/^([\w-]{11})\./) : null; if (match) owners.add(match[1]); const endpointId = item.watchEndpoint?.videoId; if (typeof endpointId === "string" && /^[\w-]{11}$/.test(endpointId)) owners.add(endpointId); }); return owners.size === 1 && owners.has(videoId); } function readCachedYouTubeNativeTranscript(track) { const page = getYouTubePageWindow(); const watch = page.document.querySelector("ytd-watch-flexy"); const videoId = extractYouTubeVideoId(); if (watch?.getAttribute("video-id") !== videoId || watch.data?.currentVideoEndpoint?.watchEndpoint?.videoId !== videoId) return []; // Both panel versions may retain text while hidden. Modern panels without // cue owner IDs are fetched again rather than risking stale text. for (const cachedPanel of page.document.querySelectorAll( 'ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-searchable-transcript"], ' + 'ytd-engagement-panel-section-list-renderer[target-id="PAmodern_transcript_view"]' )) { const cachedData = cachedPanel.data; if (isYouTubeNativeTranscriptForVideo(cachedData, videoId) && isYouTubeNativeTranscriptLanguageMatch(cachedData, track)) { const body = parseYouTubeTranscriptData(cachedData, clipState.videoDuration); if (body.length) return body; } } return []; } function isYouTubeTranscriptDataEmpty(data) { let empty = false; walkYouTubeData(data, (item) => { const list = item.transcriptSegmentListRenderer; if (Array.isArray(list?.initialSegments) && list.initialSegments.length === 0 && !list.continuations?.length) empty = true; // Generic section lists and loading renderers are not evidence of an // empty transcript: the modern panel uses them before its cues arrive. }); return empty; } async function fetchYouTubeTranscriptData(command, track, request, signal) { const page = getYouTubePageWindow(); const endpoint = command.showEngagementPanelEndpoint; const context = page.ytcfg?.get?.("INNERTUBE_CONTEXT"); if (!context?.client || typeof page.fetch !== "function") { throw new Error("YouTube 文字记录请求尚未就绪,请重试。"); } ensureSubtitleRequestActive(request); const controller = new AbortController(); const cancel = () => controller.abort(); signal?.addEventListener("abort", cancel, { once: true }); if (signal?.aborted) cancel(); const timeout = setTimeout(cancel, 8000); try { const url = new URL("/youtubei/v1/get_panel", page.location.origin); url.searchParams.set("prettyPrint", "false"); const apiKey = page.ytcfg.get("INNERTUBE_API_KEY"); if (apiKey) url.searchParams.set("key", apiKey); const requestContext = JSON.parse(JSON.stringify(context)); // The modern transcript selects its caption language from the client // locale. Always request the selected manual track, not the UI language. requestContext.client.hl = track.lan; if (command.clickTrackingParams) { requestContext.clickTracking = { clickTrackingParams: command.clickTrackingParams }; } // The modern panel's request builder sends panelId and the opaque params // from globalConfiguration. These params cannot be used with get_transcript. const response = await page.fetch(url.toString(), { method: "POST", credentials: "include", signal: controller.signal, headers: { "Content-Type": "application/json", "X-YouTube-Client-Name": String(page.ytcfg.get("INNERTUBE_CONTEXT_CLIENT_NAME") || 1), "X-YouTube-Client-Version": String(requestContext.client.clientVersion || "") }, body: JSON.stringify({ context: requestContext, panelId: endpoint.panelIdentifier || endpoint.identifier.tag, params: endpoint.globalConfiguration.params }) }); ensureSubtitleRequestActive(request); if (!response.ok) throw new Error(`YouTube 文字记录请求失败(${response.status})。`); const data = await response.json(); ensureSubtitleRequestActive(request); if (data.error) throw new Error("YouTube 文字记录请求失败,请重试。"); return data; } finally { clearTimeout(timeout); signal?.removeEventListener("abort", cancel); } } function buildYouTubeModernTranscriptCommand(videoId) { // Modern get_panel params: field 149 wraps videoId (field 1) and the // inline transcript presentation (field 3, value 2). IDs are 11 ASCII bytes. // Legacy watch pages may expose only the failing get_transcript endpoint. const params = getYouTubePageWindow().btoa( String.fromCharCode(0xaa, 0x09, 0x0f, 0x0a, 0x0b) + videoId + String.fromCharCode(0x18, 0x02) ); return { showEngagementPanelEndpoint: { identifier: { tag: "PAmodern_transcript_view" }, globalConfiguration: { params } } }; } async function fetchYouTubeTranscriptPanel(command, track, request, signal) { const data = await fetchYouTubeTranscriptData(command, track, request, signal); // Modern responses may omit language menus. The requested client locale // identifies their language; any explicit selection must still match. if (!isYouTubeNativeTranscriptLanguageMatch(data, track, true)) { throw new Error("YouTube 返回的文字记录语言不匹配,请重试。"); } const body = parseYouTubeTranscriptData(data, clipState.videoDuration); if (body.length) return { body, state: "ready" }; return { body: [], state: isYouTubeTranscriptDataEmpty(data) ? "empty" : "pending" }; } async function readYouTubeNativeTranscript(track, request, signal) { const cachedBody = readCachedYouTubeNativeTranscript(track); if (cachedBody.length) return { body: cachedBody, state: "ready" }; const page = getYouTubePageWindow(); const videoId = extractYouTubeVideoId(); const deadline = Date.now() + 6000; do { ensureSubtitleRequestActive(request); if (signal?.aborted) return { body: [], state: "cancelled" }; const watch = page.document.querySelector("ytd-watch-flexy"); // Player captions can be ready before the watch-page model after reload // or SPA navigation. Wait for the current model to avoid stale commands. if (watch?.getAttribute("video-id") === videoId && watch.data?.currentVideoEndpoint?.watchEndpoint?.videoId === videoId) { let command = null; walkYouTubeData(watch.data, (item) => { const show = item.showEngagementPanelEndpoint; if (!command && show?.identifier?.tag === "PAmodern_transcript_view" && show.globalConfiguration?.params) command = item; }); return fetchYouTubeTranscriptPanel(command || buildYouTubeModernTranscriptCommand(videoId), track, request, signal); } await sleep(150); } while (Date.now() < deadline); return { body: [], state: "pending" }; } async function fetchYouTubeSubtitleBody(url) { const request = { runId: clipState.fetchRunId, id: subtitleRequestId }; ensureSubtitleRequestActive(request); const track = clipState.subtitles.find((item) => item.subtitleUrl === url); if (!track || new URL(url).searchParams.get("v") !== extractYouTubeVideoId()) { throw new Error("字幕不属于当前 YouTube 视频。"); } const cachedBody = readCachedYouTubeNativeTranscript(track); if (cachedBody.length) return cachedBody; const controller = new AbortController(); const timedTextTask = (async () => { const response = await sendRuntimeMessage({ type: "fetch-text", url }); ensureSubtitleRequestActive(request); if (!response?.ok) throw new Error(toReadableText(response?.error, "字幕请求失败")); const text = String(response.data || ""); const body = parseYouTubeTimedText(text, clipState.videoDuration); if (body.length) return body; const error = new Error(text.trim() ? "字幕正文为空。" : "YouTube 暂未返回字幕正文,请重试。"); error.code = text.trim() ? "SUBTITLE_EMPTY" : "YOUTUBE_SUBTITLE_PENDING"; throw error; })(); const nativeTask = (async () => { const result = await readYouTubeNativeTranscript(track, request, controller.signal); ensureSubtitleRequestActive(request); if (result.body.length) return result.body; const error = new Error(result.state === "empty" ? "当前视频没有可用字幕。" : "YouTube 文字记录暂未就绪,请重试。"); error.code = result.state === "empty" ? "SUBTITLE_EMPTY" : "YOUTUBE_SUBTITLE_PENDING"; throw error; })(); try { // Start both sources immediately. A native-panel load need not wait for // the timedtext timeout, and the first usable body wins. const body = await Promise.any([timedTextTask, nativeTask]); ensureSubtitleRequestActive(request); return body; } catch (error) { ensureSubtitleRequestActive(request); const errors = error.errors || [error]; console.warn(`[Bilibili Reader] YouTube 字幕来源:${JSON.stringify(errors.map((item) => getErrorMessage(item)))}`); if (errors.every(isUnavailableSubtitleError)) { const empty = new Error("当前视频没有可用字幕。"); empty.code = "SUBTITLE_EMPTY"; throw empty; } // Empty HTTP text, unsupported fallback paths and timeouts are not proof // that an advertised caption track is absent. Keep a retryable error. throw errors.find((item) => !isUnavailableSubtitleError(item)) || error; } finally { controller.abort(); } } // One observer for the site's integration points. Ancestors are watched only // for direct replacements; comments and recommendation subtrees are excluded. const readerPageSubscriptions = new Set(); const readerPageScopes = { native: { roots: "#danmukuBox, .danmaku-box, .up-panel-container, #playerWrap, #bilibili-player, .bpx-player-container", required: "#danmukuBox, .danmaku-box", changes: "#danmukuBox, .danmaku-box, .up-panel-container, .members-info-container, #blr-native-transcript-panel, video, #playerWrap, #bilibili-player, .bpx-player-container", subtree: false }, player: { roots: "#playerWrap, #bilibili-player, .bpx-player-container, .bpx-player-mini-warp", changes: "video, #playerWrap, #bilibili-player, .bpx-player-container, .bpx-player-video-area, .bpx-player-control-wrap, .bpx-player-mini-close, .bpx-player-mini-warp", subtree: true } }; let readerPageObserver = null; let readerPageDiscoveryTimer = 0; let readerPageRefreshQueued = false; let readerPageObservedTargets = new Map(); let readerPageMissingScopes = new Set(); let readerPageEnabledScopes = new Set(); if (isYouTubePage()) { readerPageScopes.player = { roots: "ytd-watch-flexy #player, #movie_player", changes: "ytd-watch-flexy, #player, #movie_player, video", subtree: true }; readerPageScopes.native = { roots: "ytd-watch-flexy #secondary-inner, #movie_player", required: "ytd-watch-flexy #secondary-inner #related", changes: "#secondary-inner, #related, #movie_player, video, #blr-native-transcript-panel", subtree: false }; } function subscribeReaderPageChanges(scope, callback) { const subscription = { scope, callback }; readerPageSubscriptions.add(subscription); if (!readerPageObserver) readerPageObserver = new MutationObserver(handleReaderPageChanges); refreshReaderPageScopes(); return () => { readerPageSubscriptions.delete(subscription); refreshReaderPageScopes(); }; } function readerMutationTouches(record, selector) { const target = record.target instanceof Element ? record.target : record.target.parentElement; // Own content renders must not feed back into repair. An external removal // still has a site-owned parent and matches the removed integration node. if (target?.closest("#blr-root, #blr-reading-inline-host, [data-blr-extension-node]")) return false; for (const nodes of [record.addedNodes, record.removedNodes]) { for (const node of nodes) { if (node instanceof Element && (node.matches(selector) || node.querySelector(selector))) return true; } } return false; } function isReaderPageScopeEnabled(scope) { if (scope === "player") return isReaderMode(); if (scope === "native") return isSupportedTranscriptPage() && !isReaderMode(); return false; } function hasDetachedReaderPageTarget() { for (const node of readerPageObservedTargets.keys()) { if (!node.isConnected) return true; } return false; } function handleReaderPageChanges(records) { const changedScopes = new Set(); let topologyChanged = hasDetachedReaderPageTarget(); for (const subscription of readerPageSubscriptions) { if (!isReaderPageScopeEnabled(subscription.scope)) continue; if (changedScopes.has(subscription.scope)) continue; if (records.some((record) => readerMutationTouches(record, readerPageScopes[subscription.scope].changes))) { changedScopes.add(subscription.scope); } if (!topologyChanged && records.some((record) => readerMutationTouches(record, readerPageScopes[subscription.scope].roots))) topologyChanged = true; } for (const subscription of readerPageSubscriptions) { if (changedScopes.has(subscription.scope)) subscription.callback(); } // A control or subtitle content update does not change the observed roots. // Removed ancestors contain those roots, or disconnect an observed target, // so unknown site wrappers still take the repair/rebind path. if (topologyChanged && !readerPageRefreshQueued) { readerPageRefreshQueued = true; queueMicrotask(() => { readerPageRefreshQueued = false; refreshReaderPageScopes(); }); } } function scheduleReaderPageDiscovery() { if (readerPageDiscoveryTimer || !readerPageMissingScopes.size) return; readerPageDiscoveryTimer = window.setTimeout(() => { readerPageDiscoveryTimer = 0; const discovered = new Set(); let enabledChanged = false; for (const scope of readerPageEnabledScopes) { if (!isReaderPageScopeEnabled(scope)) enabledChanged = true; } // Existing roots and their ancestors remain observed while we only search // for hosts that have not arrived yet. for (const scope of readerPageMissingScopes) { if (!isReaderPageScopeEnabled(scope)) continue; const config = readerPageScopes[scope]; if (document.querySelector(config.required || config.roots)) discovered.add(scope); } const detached = hasDetachedReaderPageTarget(); if (discovered.size || detached || enabledChanged) refreshReaderPageScopes(); else scheduleReaderPageDiscovery(); for (const subscription of readerPageSubscriptions) { if (discovered.has(subscription.scope) && isReaderPageScopeEnabled(subscription.scope)) subscription.callback(); } }, 1500); } function refreshReaderPageScopes() { readerPageObserver?.disconnect(); window.clearTimeout(readerPageDiscoveryTimer); readerPageDiscoveryTimer = 0; readerPageObservedTargets = new Map(); readerPageMissingScopes = new Set(); readerPageEnabledScopes = new Set(); if (!readerPageObserver || !readerPageSubscriptions.size || !document.body) return; const targets = new Map(); const scopes = new Set([...readerPageSubscriptions].map((item) => item.scope).filter(isReaderPageScopeEnabled)); const missingScopes = new Set(); const watch = (node, subtree = false) => { if (node) targets.set(node, Boolean(targets.get(node) || subtree)); }; watch(document.body); document.querySelectorAll("#app, #video-page-app, #biliMain, #mirror-vdcon, .video-container-v1, .left-container, .right-container-inner") .forEach((node) => watch(node)); for (const scope of scopes) { const config = readerPageScopes[scope]; const roots = document.querySelectorAll(config.roots); if (!document.querySelector(config.required || config.roots)) missingScopes.add(scope); for (const root of roots) { watch(root, config.subtree || root.matches(".up-panel-container, #playerWrap, #bilibili-player, .bpx-player-container")); for (let parent = root.parentElement; parent && parent !== document.body; parent = parent.parentElement) { watch(parent); } } } for (const [target, subtree] of targets) { readerPageObserver.observe(target, { childList: true, subtree }); } readerPageObservedTargets = targets; readerPageMissingScopes = missingScopes; readerPageEnabledScopes = scopes; // During initial loading, a missing host can arrive below an unobserved // wrapper. Discovery stops as soon as every subscribed host is present. scheduleReaderPageDiscovery(); } async function enterReaderMode({ animate = false } = {}) { if (readerSessionState.open) return; if (isYouTubePage() && !extractYouTubeVideoId()) return; readerSessionState.transition?.cancel(); const sessionId = invalidateReaderSession(); const open = () => { if (sessionId !== readerSessionState.id) return; 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"); return prepareReaderMode(sessionId); }; if (!animate || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { return open(); } return transitionReaderMode(open, "enter"); } async function exitReaderMode() { if (!readerSessionState.open || readerSessionState.closing) return; readerSessionState.closing = true; readerSessionState.transition?.cancel(); invalidateReaderSession(); document.documentElement.removeAttribute("data-blr-reader-entering"); const close = () => { replaceReaderModeUrl(stripReaderModeUrl(location.href)); closeReadingView(); }; try { if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { close(); } else { await transitionReaderMode(close, "exit"); } } catch (error) { close(); throw error; } finally { readerSessionState.closing = false; } } async function transitionReaderMode(update, direction) { const root = document.documentElement; const playerHost = findReaderPlayerHost(getRuntimeVideoElement()); // Capture the layout box, not the native player's independently resized // inner surface. Both snapshots must use the same coordinate system. const player = playerHost && (getReaderPlayerWrapNode(playerHost) || playerHost); const rect = player?.getBoundingClientRect(); // Stop any in-flight smooth scroll before capturing either template. stopTranscriptScroll(document.getElementById("blr-reading-inline-host")); stopTranscriptScroll(document.getElementById(ids.nativeTranscriptList)); // Only capture a player already on screen. Direct reader links and pages // still loading use the normal mounting/retry path. if ( !document.startViewTransition || !rect || rect.width <= 0 || rect.height <= 0 || rect.bottom <= 0 || rect.top >= window.innerHeight || document.hidden ) { if (direction === "exit") { const nodes = [ document.getElementById("blr-reading-inline-host"), document.querySelector(".blr-reading-topbar") ]; await Promise.all(nodes.filter((node) => node?.animate).map((node) => { const animation = node.animate( [{ opacity: 1, transform: "translateY(0)" }, { opacity: 0, transform: "translateY(6px)" }], { duration: 150, easing: "ease-in", fill: "forwards" } ); return animation.finished.catch(() => {}).finally(() => animation.cancel()); })); return update(); } root.setAttribute("data-blr-reader-entering", "1"); try { await update(); } finally { window.setTimeout(() => root.removeAttribute("data-blr-reader-entering"), 320); } return; } const namedNodes = new Map(); const transitionStyles = new Map(); const setTransitionStyle = (name, value) => { if (!transitionStyles.has(name)) { transitionStyles.set(name, { value: root.style.getPropertyValue(name), priority: root.style.getPropertyPriority(name) }); } root.style.setProperty(name, value); }; setTransitionStyle("--blr-transition-player-width", `${rect.width}px`); setTransitionStyle("--blr-transition-player-height", `${rect.height}px`); setTransitionStyle("--blr-transition-player-from-x", `${rect.left}px`); setTransitionStyle("--blr-transition-player-from-y", `${rect.top}px`); const nameNode = (node, name) => { if (!node || namedNodes.has(node)) return; namedNodes.set(node, { value: node.style.getPropertyValue("view-transition-name"), priority: node.style.getPropertyPriority("view-transition-name") }); node.style.setProperty("view-transition-name", name); }; nameNode(player, "blr-reader-player"); const sourceTranscript = document.getElementById( direction === "enter" ? ids.nativeTranscriptPanel : "blr-reading-inline-host" ); const sourceScrollAnchor = captureTranscriptScrollAnchor( direction === "enter" ? document.getElementById(ids.nativeTranscriptList) : sourceTranscript, direction === "enter" ? ".blr-native-transcript-segment" : ".blr-reading-complete-segment" ); const sourceFollowPauseUntil = direction === "enter" ? nativeTranscriptState.manualScrollPauseUntil : readerSessionState.manualScrollPauseUntil; const transcriptRect = sourceTranscript?.getBoundingClientRect(); if (transcriptRect?.width > 0 && transcriptRect.height > 0) { nameNode(sourceTranscript, "blr-reader-transcript"); } root.setAttribute("data-blr-reader-transition", direction); let cancelled = false; let cleanedUp = false; let transition; let timeout; const cleanup = () => { if (cleanedUp) return; cleanedUp = true; window.clearTimeout(timeout); window.removeEventListener("resize", cancel); const pendingReadingRender = readerSessionState.transition?.cancel === cancel && readerSessionState.transition.pendingReadingRender; namedNodes.forEach(({ value, priority }, node) => { if (value) node.style.setProperty("view-transition-name", value, priority); else node.style.removeProperty("view-transition-name"); }); transitionStyles.forEach(({ value, priority }, name) => { if (value) root.style.setProperty(name, value, priority); else root.style.removeProperty(name); }); root.removeAttribute("data-blr-reader-transition"); root.removeAttribute("data-blr-reader-transcript-transition"); if (readerSessionState.transition?.cancel === cancel) readerSessionState.transition = null; if (readerSessionState.open && readerSessionState.layoutDirty) scheduleReaderLayout(); if (pendingReadingRender && readerSessionState.open) { renderReadingView(); syncReadingViewPlayback(true); } if (direction === "exit" && !readerSessionState.open) { clearReaderPageFocus(); scheduleNativeTranscriptPanelSync(0); cleanupReaderFloatingArtifacts(); } }; const cancel = () => { cancelled = true; transition?.skipTransition(); cleanup(); }; try { transition = document.startViewTransition(async () => { if (cancelled) return; await update(); if (cancelled || (direction === "enter" && !readerSessionState.open)) return; // The mounting code can replace the original player on watch-later pages. const nextHost = direction === "enter" ? readerPlayerState.host : findReaderPlayerHost(getRuntimeVideoElement()); const nextPlayer = nextHost && (getReaderPlayerWrapNode(nextHost) || nextHost); if (nextPlayer !== player) { player.style.removeProperty("view-transition-name"); nameNode(nextPlayer, "blr-reader-player"); } const target = nextPlayer?.getBoundingClientRect(); if (!target || target.width <= 0 || target.height <= 0) { transition.skipTransition(); return; } // Keep the captured bitmap's size constant in both directions. Only the // group's transform moves/scales it, without a second width animation. setTransitionStyle("--blr-transition-player-to-x", `${target.left}px`); setTransitionStyle("--blr-transition-player-to-y", `${target.top}px`); setTransitionStyle("--blr-transition-player-scale-x", String(target.width / rect.width)); setTransitionStyle("--blr-transition-player-scale-y", String(target.height / rect.height)); // Match the subtitle panels in both directions. The native panel must // already exist in the new snapshot instead of appearing after exit. sourceTranscript?.style.removeProperty("view-transition-name"); const targetTranscript = document.getElementById( direction === "enter" ? "blr-reading-inline-host" : ids.nativeTranscriptPanel ); const targetScrollContainer = direction === "enter" ? targetTranscript : document.getElementById(ids.nativeTranscriptList); stopTranscriptScroll(targetScrollContainer); transferTranscriptScrollAnchor(sourceScrollAnchor, targetScrollContainer, direction === "enter" ? "data-index" : "data-native-transcript-index"); if (direction === "enter") { readerSessionState.manualScrollPauseUntil = Math.max(readerSessionState.manualScrollPauseUntil, sourceFollowPauseUntil); updateReaderFollowState(); } else { nativeTranscriptState.manualScrollPauseUntil = Math.max(nativeTranscriptState.manualScrollPauseUntil, sourceFollowPauseUntil); } nameNode(targetTranscript, "blr-reader-transcript"); const targetTranscriptRect = targetTranscript?.getBoundingClientRect(); if ( transcriptRect?.width > 0 && transcriptRect.height > 0 && targetTranscriptRect?.width > 0 && targetTranscriptRect.height > 0 ) { // Translate the two real templates along the same path. Each keeps its // own dimensions and typography; only their opacity changes en route. [ ["from-x", transcriptRect.left], ["from-y", transcriptRect.top], ["to-x", targetTranscriptRect.left], ["to-y", targetTranscriptRect.top], ["from-width", transcriptRect.width], ["from-height", transcriptRect.height], ["to-width", targetTranscriptRect.width], ["to-height", targetTranscriptRect.height], ["width", Math.max(transcriptRect.width, targetTranscriptRect.width)], ["height", Math.max(transcriptRect.height, targetTranscriptRect.height)] ].forEach(([name, value]) => setTransitionStyle(`--blr-transcript-${name}`, `${value}px`)); root.setAttribute("data-blr-reader-transcript-transition", "1"); } }); } catch { cleanup(); return update(); } readerSessionState.transition = { cancel, direction, phase: "updating", finished: transition.finished }; window.addEventListener("resize", cancel, { once: true }); // A slow player must not leave the old page frozen behind a snapshot. timeout = window.setTimeout(() => transition.skipTransition(), 900); transition.ready.then(() => { window.clearTimeout(timeout); if (readerSessionState.transition?.cancel === cancel) { readerSessionState.transition.phase = "animating"; } }, () => {}); transition.finished.then(cleanup, cleanup); // Skipped animations still perform the update; never mount a second time. await transition.updateCallbackDone; if (direction === "exit") { // Keep exit guards active until all snapshots have finished animating. await transition.finished.catch(() => {}); } } const READER_MOUNT_TIMER_KEYS = ["playerMountTimer", "playerRetryTimer"]; function clearReaderMountTimers() { READER_MOUNT_TIMER_KEYS.forEach((name) => { if (readerSessionState[name]) window.clearTimeout(readerSessionState[name]); readerSessionState[name] = 0; }); } function invalidateReaderSession() { readerSessionState.id += 1; readerSessionState.mountTask = null; readerSessionState.resizeCleanup?.(false); if (readerSessionState.layoutFrame) window.cancelAnimationFrame(readerSessionState.layoutFrame); readerSessionState.layoutFrame = 0; readerSessionState.layoutDirty = false; clearReaderMountTimers(); clearReaderPlayerTimers(); return readerSessionState.id; } function isReaderSessionActive(sessionId) { return sessionId === readerSessionState.id && readerSessionState.open && isReaderMode(); } async function prepareReaderMode(sessionId = readerSessionState.id) { const readingView = byId(ids.readingView); readerSessionState.open = true; stopNativeTranscriptPlaybackSync(); ensureNativeTranscriptPanel(); document.body.setAttribute("data-blr-reading-active", "1"); hydrateReaderStateFromSettings(readerPreferences.settings); // Each entry gives the video its largest fitted size before allocating subtitles. readerSessionState.transcriptAutoWidth = true; applyReadingViewPresentation(); alignReaderViewportToPlayer(); openReaderViewShell(readingView); applyReaderPageFocus(); renderReadingView(); // Try to mount player, with more retries for slower pages (like watch later) const mounted = await ensureReaderPlayerMounted({ retries: 50, delayMs: 150, forceLayout: true }); if (!isReaderSessionActive(sessionId)) return; if (!mounted) { // Don't throw - keep UI open and keep retrying in background renderReadingStatus("正在等待视频播放器就绪..."); scheduleReaderPlayerRetry(); return; } finishEnterReaderMode(); } function scheduleReaderPlayerRetry() { if (readerSessionState.playerRetryTimer) { window.clearTimeout(readerSessionState.playerRetryTimer); readerSessionState.playerRetryTimer = 0; } const sessionId = readerSessionState.id; // Keep trying to mount player in background const tryMount = async () => { readerSessionState.playerRetryTimer = 0; if (!isReaderSessionActive(sessionId)) return; const mounted = await ensureReaderPlayerMounted({ retries: 10, delayMs: 200, forceLayout: true }); if (!isReaderSessionActive(sessionId)) return; if (mounted) { finishEnterReaderMode(); } else if (readerSessionState.open) { readerSessionState.playerRetryTimer = window.setTimeout(tryMount, 500); } }; readerSessionState.playerRetryTimer = window.setTimeout(tryMount, 500); } function finishEnterReaderMode() { if (!readerSessionState.open || !isReaderMode()) return; moveReadingMainInline(); scheduleReaderMiniPlayerDismiss(); maybeRefreshReaderSubtitleInBackground(); syncReaderModeAfterMount(); settleReaderModePresentation(); bindReaderHeaderActionsHover(); } function openReaderViewShell(readingView = byId(ids.readingView)) { if (!readingView) { return; } readingView.classList.add("open"); readingView.setAttribute("aria-hidden", "false"); setReadingViewReady(false); renderReadingStatus("正在准备播放器和字幕..."); } function maybeRefreshReaderSubtitleInBackground() { if (clipState.subtitleBody.length) { return; } const signature = computeCurrentClipSignature(); const runId = clipState.fetchRunId; const sessionId = readerSessionState.id; waitForVideoMetadata().then(() => { if (!isReaderSessionActive(sessionId) || runId !== clipState.fetchRunId || signature !== computeCurrentClipSignature()) { return; } 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; } function ensureReaderPlayerMounted(options = {}) { const sessionId = readerSessionState.id; if (!isReaderSessionActive(sessionId)) return Promise.resolve(false); if (readerSessionState.mountTask?.sessionId === sessionId) return readerSessionState.mountTask.promise; const task = { sessionId, promise: null }; readerSessionState.mountTask = task; task.promise = mountReaderPlayer(options, sessionId).finally(() => { if (readerSessionState.mountTask === task) readerSessionState.mountTask = null; }); return task.promise; } async function mountReaderPlayer({ retries = 1, delayMs = 100, forceLayout = false }, sessionId) { for (let attempt = 0; attempt < retries; attempt += 1) { if (!isReaderSessionActive(sessionId)) return false; const video = getRuntimeVideoElement(); const playerHost = findReaderPlayerHost(video); if (video && playerHost) { if (isYouTubePage()) { readerPlayerState.host = playerHost; bindReadingViewVideo(video); bindReaderLayout(); layoutReaderPlayerHost(); return true; } const previousHost = readerPlayerState.host; const previousVideo = readerPlayerState.videoEl; video.controls = false; video.removeAttribute("controls"); video.disablePictureInPicture = true; video.setAttribute("disablepictureinpicture", ""); video.removeAttribute("autopictureinpicture"); readerPlayerState.host = playerHost; const miniPlayerClosed = dismissReaderMiniPlayer(playerHost); if (miniPlayerClosed) { await sleep(120); } if (!isReaderSessionActive(sessionId)) return false; if (!video.isConnected) continue; const activeHost = findReaderPlayerHost(video) || playerHost; readerPlayerState.host = activeHost; normalizeReaderPlayerContainer(activeHost); clearNativeReaderFloatingStyles(activeHost); if (hasNativeReaderPlayerLayoutIssue(activeHost)) { normalizeReaderPlayerContainer(activeHost); clearNativeReaderFloatingStyles(activeHost); } if (previousHost && previousHost !== activeHost) { setReaderPlayerControlsVisible(false, previousHost); cleanupReaderPlayerHostNode(previousHost); } if (previousVideo !== video) { readerPlayerState.videoEventsBound = false; } activeHost.classList.add("blr-reader-player-host"); bindReadingViewVideo(video); bindReaderPlayerControlsHover(activeHost); bindReaderLayout(); if ( forceLayout || previousHost !== activeHost || attempt > 0 || miniPlayerClosed || hasNativeReaderPlayerLayoutIssue(activeHost) ) { layoutReaderPlayerHost(); if (hasNativeReaderPlayerLayoutIssue(activeHost)) { normalizeReaderPlayerContainer(activeHost); clearNativeReaderFloatingStyles(activeHost); layoutReaderPlayerHost(); } } if (!isWatchlaterPage()) { await ensureReaderPlayerControlsRecovered(activeHost, { reason: attempt > 0 ? "mount-retry" : "mount" }); if (!isReaderSessionActive(sessionId)) return false; 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 (!readerSessionState.open || !isReaderMode() || readerSessionState.playerMountTimer) { return; } const sessionId = readerSessionState.id; readerSessionState.playerMountTimer = window.setTimeout(() => { readerSessionState.playerMountTimer = 0; if (!isReaderSessionActive(sessionId)) return; ensureReaderPlayerMounted({ retries: 12, delayMs: 120, forceLayout: true }) .then((mounted) => { if (!mounted || !isReaderSessionActive(sessionId)) { return; } moveReadingMainInline(); applyReaderPageFocus(); layoutReaderPlayerHost(); syncReadingViewPlayback(true); settleReaderModePresentation(); }) .catch((error) => { logWarn("[BOC] ensure reader player mounted failed", error); }); }, 60); } function closeReadingView() { invalidateReaderSession(); const deferCleanup = readerSessionState.transition?.direction === "exit"; cleanupReaderFloatingArtifacts(); readerSessionState.open = false; readerSessionState.ready = false; readerSessionState.manualScrollPauseUntil = 0; readerSessionState.programmaticScrollUntil = 0; readerSessionState.collectionSwitchInFlight = false; readerSessionState.nextScrollBehavior = "smooth"; const readingView = byId(ids.readingView); readingView.classList.remove("open"); readingView.setAttribute("aria-hidden", "true"); readingView.setAttribute("data-blr-reader-ready", "0"); readingView.removeAttribute("data-blr-reader-follow"); clearReaderPresentationAttributes(); [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"); node.style.removeProperty("--blr-reader-player-left"); node.style.removeProperty("--blr-reader-player-top"); node.style.removeProperty("--blr-reader-player-bottom"); node.style.removeProperty("--blr-reader-player-width"); }); restoreReadingMainInline(); stopReadingViewSync(); unbindReaderLayout(); cleanupReaderPlayerHost(); if (!deferCleanup) clearReaderPageFocus(); // Restore and populate the normal-page subtitles before the new snapshot. // Observer-driven refreshes stay deferred while the snapshots animate. ensureNativeTranscriptPanel(); // Restore the sending bar together with the native layout. Hiding it for // 200ms changed the player's height in the middle of the exit transition. if (!deferCleanup) { window.setTimeout(() => cleanupReaderFloatingArtifacts(), 40); window.setTimeout(() => cleanupReaderFloatingArtifacts(), 220); } } function getReaderContentMaxPx() { if (readerPreferences.contentWidth === "compact") { return 720; } if (readerPreferences.contentWidth === "narrow") { return 820; } if (readerPreferences.contentWidth === "wide") { return 1120; } if (readerPreferences.contentWidth === "full") { return 1280; } return 980; } function getReaderPagePaddingPx() { return Math.min(32, Math.max(16, window.innerWidth * 0.028)); } function getReaderMainWidthLimit(columns = null) { const pagePadding = getReaderPagePaddingPx(); if (window.innerWidth > 1180) { const { transcriptWidth, gap } = columns || getEffectiveReaderColumnWidths(); const availableWidth = window.innerWidth - pagePadding * 2 - transcriptWidth - gap; // Fit against the real video ratio and player top in layoutReaderPlayerHost. return Math.max(1, availableWidth); } return Math.max(320, Math.min(getReaderContentMaxPx(), window.innerWidth - pagePadding * 2)); } function getReaderPlayerMaxHeightPx(playerTop) { const isDesktop = window.innerWidth > 1180; const hasChapterRail = isDesktop && getCachedReadingChapters().length > 0; // Only visible chapters reserve space below the video. const bottomSpace = hasChapterRail ? 148 : 0; let playerBottomLimit = window.innerHeight - bottomSpace; if (isDesktop) { const transcriptRect = document.getElementById("blr-reading-inline-host")?.getBoundingClientRect(); // Before the transcript mounts, use its desktop CSS bottom inset (24px). const transcriptBottom = transcriptRect?.width > 0 && transcriptRect.height > 0 ? transcriptRect.bottom : window.innerHeight - 24; playerBottomLimit = Math.min(playerBottomLimit, transcriptBottom); } return Math.max(1, playerBottomLimit - playerTop); } 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 getReaderVideoAspectRatio() { const video = readerPlayerState.videoEl || getRuntimeVideoElement(); return Number(video?.videoWidth) > 0 && Number(video?.videoHeight) > 0 ? Number(video.videoWidth) / Number(video.videoHeight) : 16 / 9; } function getEffectiveReaderColumnWidths() { const pagePadding = getReaderPagePaddingPx(); const gap = Math.min(24, Math.max(16, window.innerWidth * 0.014)); const minChapterWidth = 140; let minTranscriptWidth = 280; const minVideoWidth = 420; const chapterWidth = normalizeReaderColumnWidth(readerPreferences.chapterWidthPx, 220, minChapterWidth, 360); let maxTranscriptWidth = Math.max( minTranscriptWidth, window.innerWidth - pagePadding * 2 - gap - minVideoWidth ); if (readerSessionState.open && window.innerWidth > 1180) { const video = readerPlayerState.videoEl || getRuntimeVideoElement(); const playerHost = readerPlayerState.host || findReaderPlayerHost(video); const playerNode = getReaderPlayerWrapNode(playerHost) || playerHost; const playerTop = playerNode?.getBoundingClientRect?.().top ?? 98; const maxHeight = getReaderPlayerMaxHeightPx(playerTop); const availableWidth = window.innerWidth - pagePadding * 2 - gap; // Give the video its largest complete frame first, then use the remaining // horizontal space for subtitles instead of reserving a fixed-width panel. const videoWidth = Math.min(availableWidth - 280, maxHeight * getReaderVideoAspectRatio()); minTranscriptWidth = Math.max(280, Math.ceil(availableWidth - videoWidth)); maxTranscriptWidth = Math.max( minTranscriptWidth, Math.floor(availableWidth - Math.min(minVideoWidth, videoWidth)) ); } const transcriptWidth = readerSessionState.open && window.innerWidth > 1180 && readerSessionState.transcriptAutoWidth ? minTranscriptWidth : normalizeReaderColumnWidth(readerPreferences.transcriptWidthPx, 440, minTranscriptWidth, maxTranscriptWidth); return { chapterWidth: Math.round(chapterWidth), transcriptWidth: Math.round(transcriptWidth), minTranscriptWidth, maxTranscriptWidth, gap }; } function setReaderStyle(node, name, value) { if (node && node.style.getPropertyValue(name) !== value) node.style.setProperty(name, value); } function applyReaderColumnLayout(columns = getEffectiveReaderColumnWidths()) { const readingView = byId(ids.readingView); if (!readingView) return; const { chapterWidth, transcriptWidth, minTranscriptWidth, maxTranscriptWidth, gap } = columns; const mainWidth = getReaderMainWidthLimit(columns); const centerOffset = Math.round(-(transcriptWidth + gap) / 2); [document.documentElement, document.body, readingView].forEach((node) => { setReaderStyle(node, "--blr-reader-rail-width", `${chapterWidth}px`); setReaderStyle(node, "--blr-reader-transcript-width", `${transcriptWidth}px`); setReaderStyle(node, "--blr-reader-center-offset", `${centerOffset}px`); setReaderStyle(node, "--blr-reader-main-width", `${Math.round(mainWidth)}px`); }); const resizeHandle = document.getElementById(ids.readingTranscriptResizeHandle); resizeHandle?.setAttribute("aria-valuenow", String(transcriptWidth)); resizeHandle?.setAttribute("aria-valuemin", String(minTranscriptWidth)); resizeHandle?.setAttribute("aria-valuemax", String(maxTranscriptWidth)); } function updateReaderChapterRailPosition(rect = null) { const readingView = document.getElementById(ids.readingView); if (!readingView || window.innerWidth <= 1180) return; const playerNode = getReaderPlayerWrapNode() || readerPlayerState.host; const playerRect = rect || playerNode?.getBoundingClientRect?.(); if (!playerRect || !(playerRect.width > 0) || !(playerRect.height > 0)) return; [document.documentElement, document.body, readingView].forEach((node) => { setReaderStyle(node, "--blr-reader-player-left", `${Math.round(playerRect.left)}px`); setReaderStyle(node, "--blr-reader-player-top", `${Math.round(playerRect.top)}px`); setReaderStyle(node, "--blr-reader-player-bottom", `${Math.round(playerRect.bottom)}px`); setReaderStyle(node, "--blr-reader-player-width", `${Math.round(playerRect.width)}px`); }); } function bindReaderResizeHandle(node) { if (!node || node.dataset.blrBound === "1") return; node.addEventListener("pointerdown", (event) => { if (event.button !== 0 || window.innerWidth <= 1180 || readerSessionState.transition) return; readerSessionState.resizeCleanup?.(false); event.preventDefault(); node.setPointerCapture?.(event.pointerId); document.body.dataset.blrReaderResizing = "transcript"; noteManualReaderInteraction(); const sessionId = readerSessionState.id; const scrollAnchor = captureTranscriptScrollAnchor( document.getElementById("blr-reading-inline-host"), ".blr-reading-complete-segment" ); let frame = 0; let latestX = null; let hasMoved = false; let stopped = false; const flush = () => { if (latestX === null || !isReaderSessionActive(sessionId)) return; const { gap, minTranscriptWidth, maxTranscriptWidth } = getEffectiveReaderColumnWidths(); readerSessionState.transcriptAutoWidth = false; readerPreferences.transcriptWidthPx = normalizeReaderColumnWidth( window.innerWidth - getReaderPagePaddingPx() - latestX - gap / 2, readerPreferences.transcriptWidthPx, minTranscriptWidth, maxTranscriptWidth ); latestX = null; layoutReaderPlayerHost(); restoreTranscriptScrollAnchor(scrollAnchor); }; const move = (moveEvent) => { if (moveEvent.pointerId !== event.pointerId) return; if (!isReaderSessionActive(sessionId)) { finish(false); return; } latestX = moveEvent.clientX; hasMoved = true; if (frame) return; frame = window.requestAnimationFrame(() => { frame = 0; flush(); }); }; const end = (endEvent) => { if (endEvent.pointerId !== event.pointerId) return; if (hasMoved && endEvent.type === "pointerup") latestX = endEvent.clientX; finish(true); }; const finish = (persist = true) => { if (stopped) return; stopped = true; if (frame) window.cancelAnimationFrame(frame); frame = 0; if (persist) flush(); if (node.hasPointerCapture?.(event.pointerId)) node.releasePointerCapture(event.pointerId); document.body.removeAttribute("data-blr-reader-resizing"); window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", end); window.removeEventListener("pointercancel", end); if (readerSessionState.resizeCleanup === finish) readerSessionState.resizeCleanup = null; if (!persist || !isReaderSessionActive(sessionId)) return; noteManualReaderInteraction(); readerPreferences.settings = { ...readerPreferences.settings, readerTranscriptWidthPx: readerPreferences.transcriptWidthPx }; persistReaderSettings(); }; readerSessionState.resizeCleanup = finish; window.addEventListener("pointermove", move); window.addEventListener("pointerup", end); window.addEventListener("pointercancel", end); }); node.dataset.blrBound = "1"; } function scheduleReaderLayout() { if (!readerSessionState.open || !isReaderMode()) return; readerSessionState.layoutDirty = true; if (readerSessionState.layoutFrame || readerSessionState.transition?.phase === "animating") return; const sessionId = readerSessionState.id; readerSessionState.layoutFrame = window.requestAnimationFrame(() => { readerSessionState.layoutFrame = 0; if (!isReaderSessionActive(sessionId)) return; layoutReaderPlayerHost(); }); } function observeReaderPlayerLayout() { const player = getReaderPlayerWrapNode() || readerPlayerState.host; if (readerSessionState.observedPlayer === player) return; readerSessionState.resizeObserver?.disconnect(); readerSessionState.observedPlayer = player; if (!player) return; readerSessionState.resizeObserver = new ResizeObserver(scheduleReaderLayout); readerSessionState.resizeObserver.observe(player); } function bindReaderLayout() { observeReaderPlayerLayout(); if (readerPlayerState.layoutBound) { return; } window.addEventListener("resize", scheduleReaderLayout); window.addEventListener("scroll", scheduleReaderLayout, { passive: true }); window.visualViewport?.addEventListener("resize", scheduleReaderLayout); document.addEventListener("fullscreenchange", scheduleReaderLayout); document.addEventListener("webkitfullscreenchange", scheduleReaderLayout); readerPlayerState.layoutBound = true; } function unbindReaderLayout() { readerSessionState.resizeObserver?.disconnect(); readerSessionState.resizeObserver = null; readerSessionState.observedPlayer = null; if (readerSessionState.layoutFrame) window.cancelAnimationFrame(readerSessionState.layoutFrame); readerSessionState.layoutFrame = 0; readerSessionState.layoutDirty = false; if (!readerPlayerState.layoutBound) { return; } window.removeEventListener("resize", scheduleReaderLayout); window.removeEventListener("scroll", scheduleReaderLayout); window.visualViewport?.removeEventListener("resize", scheduleReaderLayout); document.removeEventListener("fullscreenchange", scheduleReaderLayout); document.removeEventListener("webkitfullscreenchange", scheduleReaderLayout); readerPlayerState.layoutBound = false; } // Size YouTube with scoped CSS while keeping its player in the native DOM. // Its inline dimensions and controls belong to YouTube and survive reader exit. function layoutYouTubeReaderPlayer() { if ( document.fullscreenElement || document.webkitFullscreenElement || readerPlayerState.host?.classList.contains("ytp-fullscreen") ) return; const columns = getEffectiveReaderColumnWidths(); applyReaderColumnLayout(columns); const padding = getReaderPagePaddingPx(); const top = 98; const desktop = window.innerWidth > 1180; const hasChapters = getCachedReadingChapters().length > 0; const availableWidth = desktop ? window.innerWidth - padding * 2 - columns.transcriptWidth - columns.gap : window.innerWidth - padding * 2; const availableHeight = desktop ? window.innerHeight - top - (hasChapters ? 148 : 24) : Math.min(window.innerHeight * 0.4, window.innerHeight - top - 240 - (hasChapters ? 128 : 0)); const ratio = getReaderVideoAspectRatio(); const width = Math.max(1, Math.min(availableWidth, Math.max(1, availableHeight) * ratio)); const height = width / ratio; const left = desktop ? padding : (window.innerWidth - width) / 2; [document.documentElement, document.body, byId(ids.readingView)].forEach((node) => { setReaderStyle(node, "--blr-reader-player-left", `${Math.round(left)}px`); setReaderStyle(node, "--blr-reader-player-top", `${top}px`); setReaderStyle(node, "--blr-reader-player-bottom", `${Math.round(top + height)}px`); setReaderStyle(node, "--blr-reader-player-width", `${width}px`); setReaderStyle(node, "--blr-reader-player-rendered-width", `${width}px`); setReaderStyle(node, "--blr-reader-player-rendered-height", `${height}px`); }); updateReadingTranscriptTailSpacer(); } function layoutReaderPlayerHost() { if (!readerSessionState.open || !isReaderMode()) { return; } if (readerSessionState.transition?.phase === "animating") { readerSessionState.layoutDirty = true; return; } readerSessionState.layoutDirty = false; if (isYouTubePage()) { layoutYouTubeReaderPlayer(); return; } const readingView = byId(ids.readingView); const columns = getEffectiveReaderColumnWidths(); applyReaderColumnLayout(columns); const playerHost = readerPlayerState.host; if (!playerHost) { return; } const aspectRatio = getReaderVideoAspectRatio(); const rect = playerHost.getBoundingClientRect(); if (!(rect.width > 0) || !(rect.height > 0)) { return; } const widthLimit = getReaderMainWidthLimit(columns); const wrapRect = getReaderPlayerWrapNode(playerHost)?.getBoundingClientRect?.(); const layoutTop = Number.isFinite(wrapRect?.top) ? wrapRect.top : rect.top; const maxHeight = getReaderPlayerMaxHeightPx(layoutTop); // Fit the whole video, then size the player to that same aspect ratio. // Filling the width and height independently creates letterbox bars. const renderedWidth = Math.min(widthLimit, maxHeight * aspectRatio); const renderedHeight = renderedWidth / aspectRatio; clearNativeReaderFloatingStyles(playerHost); cleanupReaderPlayerHostNode(playerHost); [document.documentElement, document.body, readingView].forEach((node) => { setReaderStyle(node, "--blr-reader-player-rendered-width", `${renderedWidth}px`); setReaderStyle(node, "--blr-reader-player-rendered-height", `${renderedHeight}px`); }); updateReaderChapterRailPosition(); updateReadingTranscriptTailSpacer(); queueEnsureReaderPlayerControlsRecovered({ reason: "layout-native", delayMs: 120 }); } const nativeTranscriptRenderCache = new WeakMap(); function startNativeTranscriptPanelObserver() { if (nativeTranscriptState.observer || !document.body) { return; } nativeTranscriptState.observer = subscribeReaderPageChanges( "native", () => scheduleNativeTranscriptPanelSync() ); window.addEventListener("resize", () => scheduleNativeTranscriptPanelSync(60), { passive: true }); scheduleNativeTranscriptPanelSync(0); } function scheduleNativeTranscriptPanelSync(delayMs = 120) { if (nativeTranscriptState.syncTimer) { return; } nativeTranscriptState.syncTimer = window.setTimeout(() => { nativeTranscriptState.syncTimer = 0; // closeReadingView restores the panel before the exit snapshot. Keep // observer-driven refreshes out of the animation; completion syncs again. if (readerSessionState.transition?.direction === "exit") return; ensureNativeTranscriptPanel(); }, Math.max(0, Number(delayMs) || 0)); } function shouldShowNativeTranscriptPanel() { return isSupportedTranscriptPage() && !isReaderMode() && !readerSessionState.open; } function findNativeTranscriptAnchor() { if (isYouTubePage()) return document.querySelector("ytd-watch-flexy #secondary-inner #related"); const danmaku = document.getElementById("danmukuBox") || document.querySelector(".danmaku-box"); const rightContainer = danmaku?.closest(".right-container-inner"); const collaborationPanel = rightContainer?.querySelector( ":scope > .up-panel-container .members-info-container" ); const collaborationAnchor = collaborationPanel?.closest(".up-panel-container"); return collaborationAnchor || danmaku; } function ensureNativeTranscriptPanel() { const existing = document.getElementById(ids.nativeTranscriptPanel); if (!shouldShowNativeTranscriptPanel()) { existing?.parentElement?.removeAttribute("data-blr-youtube-transcript-open"); stopNativeTranscriptPlaybackSync(); nativeTranscriptState.resizeObserver?.disconnect(); nativeTranscriptState.observedPlayer = null; existing?.remove(); return null; } const anchor = findNativeTranscriptAnchor(); if (!anchor?.parentElement) { return null; } let panel = existing; const created = !panel; if (!panel) { panel = document.createElement("section"); panel.id = ids.nativeTranscriptPanel; panel.className = "blr-native-transcript-panel"; panel.setAttribute("data-blr-extension-node", "native-transcript"); panel.innerHTML = readerHtml(`