优化,阅读问题处理

This commit is contained in:
truewhile
2026-10-10 11:26:47 +08:00
parent 374ee28f01
commit 4ed2edab5b
36 changed files with 3921 additions and 140 deletions
+33
View File
@@ -122,6 +122,29 @@ export interface ReaderChapterContent {
comments?: ReaderContentComment[]
/** 该音轨走了服务端转码(源格式浏览器解不了),首次播放需要等转码完成。 */
transcoding?: boolean
/** 该音轨是 HLS 播放列表(m3u8),前端据此走 hls.js 而不是原生 audio。 */
hls?: boolean
}
/** 批量取正文的单章结果(对应服务端 ContentBatchItem)。 */
export interface ReaderContentBatchItem {
chapter_index: number
type?: 'text' | 'audio' | 'image'
content?: string
tracks?: string[]
images?: string[]
image_style?: string
hls?: boolean
comments?: ReaderContentComment[]
/** 由服务端持久缓存直接命中(本次未访问书源)。 */
cached?: boolean
error?: string
}
/** 批量取正文的汇总(对应服务端 ContentBatchResult)。 */
export interface ReaderContentBatchResult {
items: ReaderContentBatchItem[]
stats: { hit: number; miss: number; failed: number }
}
/** 正文里的一条段评(服务端从 <comment> / 内嵌评论图里解析出来)。 */
@@ -378,6 +401,16 @@ export const readerAPI = {
})
.then((r) => r.data),
// 批量取正文(窗口预取 / 离线缓存):单章失败不中断整批,命中持久缓存的章节零网络开销。
bookContentBatch: (id: string, chapterIndexes: number[], signal?: AbortSignal) =>
api
.post<ReaderContentBatchResult>(
`/reader/books/${id}/content-batch`,
{ chapter_indexes: chapterIndexes },
{ timeout: LONG_REQUEST_TIMEOUT, signal },
)
.then((r) => r.data),
// ── 替换净化规则 ──
listReplaceRules: () =>
api.get<{ rules: ReaderReplaceRule[] }>('/reader/replace-rules').then((r) => r.data.rules),
+50 -12
View File
@@ -37,6 +37,7 @@ import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { useReaderAudioStore } from '../../stores/readerAudio'
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import { formatScrollPct, shouldSaveScrollProgress } from '../../utils/readerScrollProgress'
import BrowserPanel from './BrowserPanel'
import { ReaderAudioPanel } from './ReaderAudioPanel'
import { ReaderComic } from './ReaderComic'
@@ -66,6 +67,13 @@ const IMG_MARK = '[img]'
/** 听书进度上报节流(毫秒):按秒记忆,退出最多丢 5 秒。 */
const AUDIO_SAVE_INTERVAL_MS = 5_000
/**
* 正文窗口预取章数:当前章之后预取 3 章。
* 单章串行预取在高延迟书源上常常赶不上连续翻章;批量接口在服务端并发抓取并落
* 持久缓存(对应 legado CacheBook),翻到后续章时直接命中。
*/
const PREFETCH_WINDOW = 3
/** 漫画进度上报节流(毫秒):按图片序号记忆。 */
const COMIC_SAVE_INTERVAL_MS = 2_000
@@ -567,6 +575,7 @@ export default function ReaderViewPage() {
chapters,
chapterIndex,
track: ct.tracks[0],
hls: ct.hls ?? false,
transcoding: ct.transcoding ?? false,
openCredits: book.open_credits ?? 0,
closeCredits: book.close_credits ?? 0,
@@ -579,14 +588,34 @@ export default function ReaderViewPage() {
readerAPI
.saveProgress(book.id, { chapter_index: chapterIndex, pos: savedPos, chapter_title: ch.title })
.catch(() => undefined)
// 预取下一章
if (!contentCache.current.has(contentCacheKey(chapterIndex + 1))) {
// 窗口预取:往后取几章(默认 3),一次批量请求由服务端并发抓取并落持久缓存。
// 批量接口单章失败不影响其它章,命中的章节在服务端零网络开销。
const prefetchIndexes: number[] = []
for (let i = 1; i <= PREFETCH_WINDOW; i++) {
const idx = chapterIndex + i
if (idx >= chapters.length) break
if (chapters[idx]?.is_volume) continue
if (contentCache.current.has(contentCacheKey(idx))) continue
prefetchIndexes.push(idx)
}
if (prefetchIndexes.length > 0) {
readerAPI
.bookContent(book.id, chapterIndex + 1, ac.signal)
.then((c) => {
.bookContentBatch(book.id, prefetchIndexes, ac.signal)
.then((result) => {
// 预取是在「旧源」发起、在换源后才回来的话,结果属于脏数据,丢掉。
if (cancelled) return
contentCache.current.set(contentCacheKey(chapterIndex + 1), c)
for (const item of result.items) {
if (item.error || !item.type) continue
contentCache.current.set(contentCacheKey(item.chapter_index), {
type: item.type as 'text' | 'audio' | 'image',
content: item.content,
tracks: item.tracks,
images: item.images,
image_style: item.image_style,
hls: item.hls,
comments: item.comments,
})
}
})
.catch(() => undefined)
}
@@ -881,15 +910,22 @@ export default function ReaderViewPage() {
if (menuOpen) flushTextPageReactSync()
}, [menuOpen, flushTextPageReactSync])
// 滚动模式恢复进度
// 滚动模式恢复进度。
//
// 同时清掉 dataset.last:它是「上次保存的滚动百分比」,换章后必须重算。
// 否则新章首屏若与上一章末尾的百分比差值不到 2%,首次滚动会被节流掉,
// 退出后进度又回到旧位置。
useEffect(() => {
if (settings.pageMode !== 'scroll' || content === null) return
const el = scrollRef.current
if (el && pendingPosRef.current > 0) {
el.scrollTop = pendingPosRef.current
pendingPosRef.current = 0
if (el) {
delete el.dataset.last
if (pendingPosRef.current > 0) {
el.scrollTop = pendingPosRef.current
pendingPosRef.current = 0
}
}
}, [content, settings.pageMode])
}, [content, chapterIndex, settings.pageMode])
// 滚动模式的滚轮改成「类手机滑动」:把滚轮格数累加成目标位置再逐帧逼近,
// 滚动连续、松手后自己滑行一段,而不是浏览器整格跳变。
@@ -902,6 +938,8 @@ export default function ReaderViewPage() {
const savePos = useCallback(
(pos: number) => {
if (!book || chapterIndex === null) return
// 滚动位置读自 DOM,异常时可能是 NaN;落库前挡掉,避免把进度写坏。
if (!Number.isFinite(pos)) return
const ch = chapters[chapterIndex]
readerAPI
.saveProgress(book.id, {
@@ -1531,8 +1569,8 @@ export default function ReaderViewPage() {
const max = el.scrollHeight - el.clientHeight
if (max > 0) {
const pct = el.scrollTop / max
if (Math.abs(pct * 1000 - (Number(el.dataset.last) ?? -1) * 1000) > 20) {
el.dataset.last = String(pct)
if (shouldSaveScrollProgress(pct, el.dataset.last)) {
el.dataset.last = formatScrollPct(pct)
savePos(Math.round(el.scrollTop))
}
}
+21 -10
View File
@@ -31,6 +31,12 @@ export interface LoadChapterInput {
chapterIndex: number
/** 当前章的音轨地址(已签名)。 */
track: string
/**
* 音轨是否为 HLS 播放列表(服务端下发)。
* 代理地址是 base64 的 /api/reader/media?u=...,前端无法再从后缀判断格式,
* 必须用这个标志决定走 hls.js 还是原生 <audio>。
*/
hls?: boolean
transcoding: boolean
openCredits: number
closeCredits: number
@@ -65,7 +71,7 @@ class ReaderAudioEngine {
/** 加载批次号:动态 import hls.js 期间被新的加载打断时用来丢弃旧结果。 */
private loadToken = 0
/** 预取缓存:章序号 → 已解析的音轨。命中的切章可以同步开播,不用等网络。 */
private prefetched = new Map<number, { track: string; transcoding: boolean }>()
private prefetched = new Map<number, { track: string; transcoding: boolean; hls: boolean }>()
/** 预取在途的章序号,避免同一章重复请求。 */
private prefetching = new Set<number>()
/** 预热下一章字节的隐藏播放器(只下载,从不 play())。 */
@@ -141,7 +147,7 @@ class ReaderAudioEngine {
if (preset > 0) this.applyTimer(preset)
}
void this.attachSource(input.track)
void this.attachSource(input.track, input.hls === true)
// 提前解析下一章的音轨并在后台预热字节:息屏自动续播时才能立刻接上
const nextIndex = this.neighborChapterIndex(1)
if (nextIndex !== null) this.prefetchChapter(nextIndex)
@@ -258,7 +264,11 @@ class ReaderAudioEngine {
// ─── 音源装载 ─────────────────────────────────────────────────────────
private async attachSource(src: string): Promise<void> {
/**
* 装上音源。isHLS 由服务端判定(见 LoadChapterInput.hls)——
* 代理地址不含 .m3u8 后缀,前端不能再用地址猜格式。
*/
private async attachSource(src: string, isHLS: boolean): Promise<void> {
const token = ++this.loadToken
// 只销毁上一章的 hls,不再 removeAttribute('src') + load()。load() 会把媒体
// 元素彻底卸载、释放系统音频会话,息屏时紧接着的 play() 就会被浏览器当成
@@ -276,7 +286,7 @@ class ReaderAudioEngine {
// 先停住上一章:HLS 分支要 await 动态 import,不先停会有一小段两章叠播
if (!this.audio.paused) this.audio.pause()
this.setSessionMetadata(src)
if (src.includes('.m3u8')) {
if (isHLS) {
const mod = await import('hls.js')
// 动态 import 期间可能已经切到别的章:丢弃这次结果
if (token !== this.loadToken) return
@@ -329,19 +339,19 @@ class ReaderAudioEngine {
if (useReaderAudioStore.getState().bookId !== forBook) return
const track = ct.tracks?.[0]
if (ct.type !== 'audio' || !track) return
this.prefetched.set(index, { track, transcoding: ct.transcoding ?? false })
this.prefetched.set(index, { track, transcoding: ct.transcoding ?? false, hls: ct.hls ?? false })
// 要服务端转码的音轨立刻预热(把转码提前跑掉);直链等到本章快播完再拉字节,
// 避免用户中途停下时白下整章的流量。
if (ct.transcoding) this.warmNextBytes(track)
if (ct.transcoding) this.warmNextBytes(track, ct.hls ?? false)
})
.catch(() => undefined)
.finally(() => this.prefetching.delete(index))
}
/** 用隐藏播放器把下一章的字节(以及服务端转码)提前拉起来,减少切章等待。 */
private warmNextBytes(track: string): void {
private warmNextBytes(track: string, isHLS: boolean): void {
// HLS 交给 hls.js 按需拉流,二次下载没有意义
if (!track || track.includes('.m3u8') || this.warmedTrack === track) return
if (!track || isHLS || this.warmedTrack === track) return
this.warmedTrack = track
if (!this.warmer) {
const a = new Audio()
@@ -359,7 +369,7 @@ class ReaderAudioEngine {
const nextIndex = this.neighborChapterIndex(1)
if (nextIndex === null) return
const hit = this.prefetched.get(nextIndex)
if (hit) this.warmNextBytes(hit.track)
if (hit) this.warmNextBytes(hit.track, hit.hls)
}
/** 用预取结果接续下一章;命中返回 true(未命中由调用方走异步加载兜底)。 */
@@ -375,6 +385,7 @@ class ReaderAudioEngine {
chapters: s.chapters,
chapterIndex: index,
track: hit.track,
hls: hit.hls,
transcoding: hit.transcoding,
openCredits: s.openCredits,
closeCredits: s.closeCredits,
@@ -383,7 +394,7 @@ class ReaderAudioEngine {
// 立刻在 ended 的同一次事件里发起 play(),保证没有空档。片头跳转由
// loadedmetadata 处理器完成:音频在 loadedmetadata 之前不会出声,所以
// 先 play() 也不会漏出片头。HLS 交给 hls.js 承载,无法同帧开播。
if (!hit.track.includes('.m3u8')) this.play()
if (!hit.hls) this.play()
return true
}
@@ -0,0 +1,39 @@
import {
SCROLL_SAVE_THRESHOLD_PCT,
formatScrollPct,
parseLastScrollPct,
shouldSaveScrollProgress,
} from './readerScrollProgress.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`readerScrollProgress: ${name}`)
}
// 回归:dataset.last 缺失时 Number(undefined) 是 NaN,旧实现用 `?? -1` 接不住,
// 比较恒为 false,滚动进度永远不保存。首次滚动必须触发。
check('missing dataset triggers the first save', shouldSaveScrollProgress(0.1, undefined) === true)
// 空串同样要按「没有历史值」处理。
check('empty dataset triggers save', shouldSaveScrollProgress(0.1, '') === true)
// 变化不足阈值不保存(避免每帧都上报)。
check('small delta is throttled', shouldSaveScrollProgress(0.101, '0.1') === false)
// 达到阈值保存。
check('threshold delta saves', shouldSaveScrollProgress(0.13, '0.1') === true)
check('backward scroll saves', shouldSaveScrollProgress(0.07, '0.1') === true)
// 边界:正好等于阈值。
const justAt = 0.1 + SCROLL_SAVE_THRESHOLD_PCT / 100
check('exactly at threshold saves', shouldSaveScrollProgress(justAt, '0.1') === true)
// 非法输入不触发保存。
check('non-finite current pct is ignored', shouldSaveScrollProgress(NaN, '0.1') === false)
check('corrupt dataset value is treated as missing', shouldSaveScrollProgress(0.5, 'not-a-number') === true)
// 解析与格式化往返。
check('parse keeps finite values', parseLastScrollPct('0.25') === 0.25)
check('parse rejects garbage', Number.isNaN(parseLastScrollPct('x')))
check('format round-trips', parseLastScrollPct(formatScrollPct(0.375)) === 0.375)
console.log('readerScrollProgress tests passed')
+38
View File
@@ -0,0 +1,38 @@
/**
* 滚动阅读的进度保存节流。
*
* 只按滚动百分比的变化幅度决定是否上报,避免每帧都发保存请求。
* 状态存在 DOM 的 dataset 上(滚动是热路径,不走 React state)。
*/
/** 触发保存的最小百分比变化(2%)。 */
export const SCROLL_SAVE_THRESHOLD_PCT = 2
/**
* 读取上次保存的滚动百分比。
*
* 必须用 Number.isFinite 判断:dataset 缺失时 Number(undefined) 得到 NaN,
* 而 NaN 与任何值比较都是 false,会导致首次滚动永远不保存(进度丢失)。
*/
export function parseLastScrollPct(raw: string | undefined): number {
const n = Number.parseFloat(raw ?? '')
return Number.isFinite(n) ? n : NaN
}
/**
* 当前滚动百分比是否应该触发保存。
*
* @param currentPct 当前滚动百分比(0~1)
* @param lastRaw 上次保存的原始值(dataset.last)
*/
export function shouldSaveScrollProgress(currentPct: number, lastRaw: string | undefined): boolean {
if (!Number.isFinite(currentPct)) return false
const last = parseLastScrollPct(lastRaw)
if (!Number.isFinite(last)) return true
return Math.abs(currentPct - last) * 100 >= SCROLL_SAVE_THRESHOLD_PCT
}
/** 把百分比格式化成保存用的字符串。 */
export function formatScrollPct(pct: number): string {
return String(pct)
}