bug处理

This commit is contained in:
truewhile
2026-10-02 12:02:29 +08:00
parent 5acfa6292a
commit b8648f0049
9 changed files with 563 additions and 17 deletions
+96 -5
View File
@@ -12,7 +12,7 @@ import {
Timer,
X,
} from 'lucide-react'
import { Virtuoso } from 'react-virtuoso'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import type { ReaderChapter } from '../../api/reader'
import {
@@ -21,6 +21,7 @@ import {
AUDIO_TIMERS,
useReaderSettingsStore,
} from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import { readerCoverSrc } from '../../utils/readerCover'
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
@@ -48,6 +49,11 @@ interface ReaderAudioPanelProps {
/** 该音轨由服务端转码,首次播放需要等转码完成。 */
transcoding?: boolean
onProgress: (seconds: number) => void
/**
* 立即落一次进度(不节流)。只在「用户要离开这一章」时用(切后台/关页面),
* 把节流窗口里最后几秒补上;正常播放走 onProgress 的节流上报。
*/
onCommitProgress: (seconds: number) => void
onPrevChapter: () => void
onNextChapter: () => void
onSelectChapter: (index: number) => void
@@ -91,6 +97,7 @@ export function ReaderAudioPanel({
hasNext,
transcoding = false,
onProgress,
onCommitProgress,
onPrevChapter,
onNextChapter,
onSelectChapter,
@@ -102,6 +109,8 @@ export function ReaderAudioPanel({
const hlsRef = useRef<{ destroy: () => void } | null>(null)
const restoredRef = useRef(false)
const skippedEndRef = useRef(false)
/** 章节抽屉的虚拟列表句柄:分组下拉靠它整段跳转。 */
const tocRef = useRef<VirtuosoHandle>(null)
const speed = useReaderSettingsStore((s) => s.audioSpeed)
const setSpeed = useReaderSettingsStore((s) => s.setAudioSpeed)
@@ -115,6 +124,16 @@ export function ReaderAudioPanel({
// 音轨加载失败(格式不支持 / 转码失败):界面上要给出原因,不能一直停在「加载中」
const [audioError, setAudioError] = useState('')
const [sheet, setSheet] = useState<Sheet>('none')
/** 章节抽屉顶部「区间下拉」当前选中的组号,随列表滚动同步。 */
const [groupIndex, setGroupIndex] = useState(0)
// 当前音轨的最后播放位置(秒)。timeupdate 只在播放中触发,离开页面时要用它补报一次。
const lastTimeRef = useRef(0)
// onCommitProgress 每次渲染都可能换实例(父级闭包里的 chapterIndex 会变),
// 卸载时只能通过 ref 拿到最新的那个,否则会写错章节。
const commitRef = useRef(onCommitProgress)
useEffect(() => {
commitRef.current = onCommitProgress
}, [onCommitProgress])
// 封面可能被防盗链挡掉:加载失败就退回主题色占位,不留破图
const [coverOK, setCoverOK] = useState(true)
useEffect(() => {
@@ -145,6 +164,8 @@ export function ReaderAudioPanel({
setAudioError('')
setCur(0)
setDur(0)
// 换章时清零:否则离开页面时会把上一章的位置补报成新章节的进度
lastTimeRef.current = 0
let cancelled = false
let hls: { destroy: () => void } | null = null
@@ -184,6 +205,27 @@ export function ReaderAudioPanel({
if (audio) audio.playbackRate = speed
}, [speed])
// ── 离开这一章时补报进度 ──
// 进度平时靠 timeupdate 节流上报(听书 5s 一次),而 timeupdate 只在播放中触发:
// 用户按退出/切后台那一瞬间的位置没人上报,节流窗口内最后几秒就丢了。
// 这里只处理「离开」事件:换章不会卸载本面板(src 变化时 lastTimeRef 已清零),
// 因此不会把上一章的位置写到新章节头上。
useEffect(() => {
const flush = () => {
if (lastTimeRef.current > 0) commitRef.current(lastTimeRef.current)
}
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') flush()
}
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('pagehide', flush)
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('pagehide', flush)
flush()
}
}, [])
// ── 定时关闭 ──
const applyTimer = useCallback(
(minutes: number) => {
@@ -277,13 +319,33 @@ export function ReaderAudioPanel({
const closeSheet = () => setSheet('none')
const openSheet = (next: Sheet) => setSheet((cur) => (cur === next ? 'none' : next))
const openSheet = (next: Sheet) => {
if (sheet === next) {
setSheet('none')
return
}
// 每次打开章节抽屉都把区间下拉对齐到当前章节:上次可能停在第 3 组,
// 续读已到第 12 组,沿用旧值会显示错误的区间。
if (next === 'chapters') setGroupIndex(chapterGroupIndexOf(chapterIndex ?? 0, chapters.length))
setSheet(next)
}
const chapterList = useMemo(
() => chapters.map((c, i) => ({ ...c, i })),
[chapters],
)
// 上千章的目录整段跳转:每 100 条一组,下拉里选区间即可(见 utils/chapterGroups.ts)。
// 目录不足一组(≤100 章)时不显示下拉——那时一屏能扫完,多了反而是噪声。
const chapterGroups = useMemo(() => buildChapterGroups(chapterList.length), [chapterList.length])
const jumpToChapterGroup = (next: number) => {
const group = chapterGroups[next]
if (!group) return
setGroupIndex(next)
tocRef.current?.scrollToIndex({ index: group.start, align: 'start' })
}
return (
<div
className="relative flex h-full flex-col overflow-hidden"
@@ -504,18 +566,46 @@ export function ReaderAudioPanel({
{sheet === 'chapters' && (
<div className="flex h-full flex-col">
<div
className="flex items-center justify-between border-b px-4 py-3 text-xs font-bold"
className="flex items-center gap-2 border-b px-4 py-3 text-xs font-bold"
style={{ borderColor: theme.text + '22' }}
>
<span>章节({chapters.length})</span>
<button type="button" onClick={closeSheet} className="opacity-70 hover:opacity-100">
<span className="shrink-0">章节({chapters.length})</span>
{chapterGroups.length > 1 && (
<select
value={groupIndex}
onChange={(e) => jumpToChapterGroup(Number(e.target.value))}
aria-label="按区间快速定位章节"
title="按区间快速定位章节"
className="min-w-0 flex-1 rounded-lg border bg-transparent px-2 py-1 text-2xs font-normal outline-none"
style={{ borderColor: theme.text + '33', color: theme.text }}
>
{chapterGroups.map((group) => (
<option
key={group.index}
value={group.index}
style={{ color: '#111827', backgroundColor: '#ffffff' }}
>
{group.label}
</option>
))}
</select>
)}
<button
type="button"
onClick={closeSheet}
className="shrink-0 opacity-70 hover:opacity-100"
>
<X size={16} />
</button>
</div>
<div className="h-[45vh] min-h-0">
<Virtuoso
ref={tocRef}
data={chapterList}
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
rangeChanged={(range) =>
setGroupIndex(chapterGroupIndexOf(range.startIndex, chapterList.length))
}
itemContent={(_i, row) => {
const isCurrent = row.i === chapterIndex
return (
@@ -717,6 +807,7 @@ export function ReaderAudioPanel({
onTimeUpdate={(e) => {
const audio = e.currentTarget
const t = audio.currentTime
lastTimeRef.current = t
setCur(t)
onProgress(t)
// 片尾跳过(legado upPlayProgress:durP >= duration - skipEnds 即当播完)
+63 -4
View File
@@ -14,10 +14,11 @@ import {
ScrollText,
Sun,
} from 'lucide-react'
import { Virtuoso } from 'react-virtuoso'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import { ReaderAudioPanel } from './ReaderAudioPanel'
import { ReaderComic } from './ReaderComic'
import { SourcePickerDialog } from './SourcePickerDialog'
@@ -71,6 +72,8 @@ export default function ReaderViewPage() {
const [menuOpen, setMenuOpen] = useState(false)
const [panel, setPanel] = useState<'none' | 'toc' | 'style'>('none')
/** 目录面板顶部「区间下拉」选中的组号,随列表滚动同步。 */
const [tocGroupIndex, setTocGroupIndex] = useState(0)
// 换源:候选源来自按书名重新搜索的结果;reloadKey 变化时整本书重新加载
const [switchOpen, setSwitchOpen] = useState(false)
const [switchLoading, setSwitchLoading] = useState(false)
@@ -85,6 +88,8 @@ export default function ReaderViewPage() {
const wheelLastEventRef = useRef(0)
const wheelLastTurnRef = useRef(0)
const wheelSwipeLockedRef = useRef(false)
/** 目录面板虚拟列表句柄:区间下拉靠它整段跳转。 */
const tocRef = useRef<VirtuosoHandle>(null)
// 分页状态
const viewportRef = useRef<HTMLDivElement>(null)
@@ -339,6 +344,35 @@ export default function ReaderViewPage() {
setChapterIndex(idx)
}, [chapters.length])
// ── 目录面板的分段跳转(对齐听书面板的区间下拉) ──
// 上千章的目录按 100 章一组切开,顶部下拉选区间即整段跳转(见 utils/chapterGroups.ts)。
// 目录不足一组(≤100 章)时不显示下拉——一屏能扫完,多一个控件只是噪声。
const tocGroups = useMemo(() => buildChapterGroups(chapters.length), [chapters.length])
const jumpToTocGroup = useCallback(
(next: number) => {
const group = tocGroups[next]
if (!group) return
setTocGroupIndex(next)
tocRef.current?.scrollToIndex({ index: group.start, align: 'start' })
},
[tocGroups],
)
// 面板开关:打开目录时把区间下拉对齐到当前章节所在组。上次可能停在第 3 组,
// 续读已到第 12 组,沿用旧值会显示错误的区间。
const openPanel = useCallback(
(next: 'none' | 'toc' | 'style') => {
if (panel === next) {
setPanel('none')
return
}
if (next === 'toc') setTocGroupIndex(chapterGroupIndexOf(chapterIndex ?? 0, chapters.length))
setPanel(next)
},
[chapterIndex, chapters.length, panel],
)
// 听书:是否还有下一章(片尾跳过/播完时决定续播还是停住)
const hasNextAudioChapter = useMemo(() => {
if (chapterIndex === null) return false
@@ -621,6 +655,7 @@ export default function ReaderViewPage() {
hasNext={hasNextAudioChapter}
transcoding={media.transcoding ?? false}
onProgress={throttledMediaSave}
onCommitProgress={savePos}
onPrevChapter={() => goChapter(-1)}
onNextChapter={() => goChapter(1)}
onSelectChapter={jumpToChapter}
@@ -814,8 +849,8 @@ export default function ReaderViewPage() {
{/* 动作行(目录 / 界面 / 夜间 / 模式 / 换源) */}
<div className={`${canSwitchSource ? 'grid-cols-5' : 'grid-cols-4'} grid pt-1`} style={{ color: theme.text }}>
{([
{ icon: <LayoutList size={18} />, label: '目录', action: () => setPanel(panel === 'toc' ? 'none' : 'toc') },
{ icon: <BookOpen size={18} />, label: '界面', action: () => setPanel(panel === 'style' ? 'none' : 'style') },
{ icon: <LayoutList size={18} />, label: '目录', action: () => openPanel('toc') },
{ icon: <BookOpen size={18} />, label: '界面', action: () => openPanel('style') },
{
icon: settings.night ? <Sun size={18} /> : <Moon size={18} />,
label: settings.night ? '日间' : '夜间',
@@ -927,15 +962,39 @@ export default function ReaderViewPage() {
>
<ArrowLeft size={18} />
</button>
<p className="flex-1 truncate text-sm font-bold">
<p className="min-w-0 flex-1 truncate text-sm font-bold">
{book?.name ?? '目录'}
<span className="ml-2 text-2xs font-normal opacity-60">目录({chapters.length} 章)</span>
</p>
{tocGroups.length > 1 && (
<select
value={tocGroupIndex}
onChange={(e) => jumpToTocGroup(Number(e.target.value))}
aria-label="按区间快速定位章节"
title="按区间快速定位章节"
className="shrink-0 rounded-lg border bg-transparent px-2 py-1 text-2xs outline-none"
style={{ borderColor: theme.text + '33', color: theme.text }}
>
{tocGroups.map((group) => (
<option
key={group.index}
value={group.index}
style={{ color: '#111827', backgroundColor: '#ffffff' }}
>
{group.label}
</option>
))}
</select>
)}
</div>
<div className="min-h-0 flex-1">
<Virtuoso
ref={tocRef}
data={chapters}
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
rangeChanged={(range) =>
setTocGroupIndex(chapterGroupIndexOf(range.startIndex, chapters.length))
}
itemContent={(index, ch) => {
const isCurrent = index === chapterIndex
return (