优化阅读功能

This commit is contained in:
truewhile
2026-10-01 21:57:25 +08:00
parent c1ff55ffbf
commit ce4319ec25
56 changed files with 9511 additions and 354 deletions
+629 -73
View File
@@ -1,56 +1,147 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight, Loader2, Pause, Play } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import {
ChevronLeft,
ChevronRight,
Gauge,
ListMusic,
Loader2,
Pause,
Play,
Scissors,
Timer,
X,
} from 'lucide-react'
import { Virtuoso } from 'react-virtuoso'
// 音频播放面板(仿 legado ReadAloudDialog transport 行):
// hls.js 播 m3u8,<audio> 播直链;上一章/播放暂停/下一章 + 进度条 + 倍速。
import type { ReaderChapter } from '../../api/reader'
import {
AUDIO_CREDITS_MAX,
AUDIO_SPEEDS,
AUDIO_TIMERS,
useReaderSettingsStore,
} from '../../stores/readerSettings'
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
// hls.js 播 m3u8,<audio> 播直链。
// transport:上一章 | -15s | 播放暂停 | +15s | 下一章;
// 动作行:章节选择、定时关闭、倍速、跳过片头片尾。
// 片头片尾按 legado 语义:全新开播(进度 0)时 seek 到 openCredits,播放到
// duration - closeCredits 即等同播完自动下一章;两者都以秒计、0 表示不跳过。
interface ReaderAudioPanelProps {
src: string
title: string
/** 书籍封面:做背景(强模糊)与居中圆形封面,对应 legado ivBg / ivCover。 */
cover: string
theme: { bg: string; text: string; accent: string }
initialPos: number // 恢复进度(秒)
/** 片头跳过秒数(Book.openCredits)。 */
openCredits: number
/** 片尾跳过秒数(Book.closeCredits)。 */
closeCredits: number
chapters: ReaderChapter[]
chapterIndex: number | null
/** 是否还有下一章(决定片尾跳过/播完是续播还是停住)。 */
hasNext: boolean
/** 该音轨由服务端转码,首次播放需要等转码完成。 */
transcoding?: boolean
onProgress: (seconds: number) => void
onPrevChapter: () => void
onNextChapter: () => void
onSelectChapter: (index: number) => void
onEnded: () => void
onCreditsChange: (open: number, close: number) => void
onToggleMenu: () => void
}
const RATES = [0.75, 1, 1.25, 1.5, 2]
/** -15s / +15s 步长(legado AudioPlayActivity.SEEK_STEP)。 */
const SEEK_STEP = 15
/** 片头片尾滑杆步进(秒)。 */
const CREDIT_STEP = 5
/** 动作行高度 76px:抽屉与遮罩都从动作行上沿开始,保证动作行常驻可点。 */
const ACTION_BAR_BOTTOM = 'bottom-[76px]'
type Sheet = 'none' | 'chapters' | 'timer' | 'speed' | 'credits'
function fmt(sec: number): string {
if (!Number.isFinite(sec)) return '0:00'
if (!Number.isFinite(sec) || sec < 0) return '0:00'
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${String(s).padStart(2, '0')}`
}
function fmtCountdown(sec: number): string {
const m = Math.floor(sec / 60)
const s = sec % 60
return `${m}:${String(s).padStart(2, '0')}`
}
export function ReaderAudioPanel({
src,
title,
cover,
theme,
initialPos,
openCredits,
closeCredits,
chapters,
chapterIndex,
hasNext,
transcoding = false,
onProgress,
onPrevChapter,
onNextChapter,
onSelectChapter,
onEnded,
onCreditsChange,
onToggleMenu,
}: ReaderAudioPanelProps) {
const audioRef = useRef<HTMLAudioElement>(null)
const hlsRef = useRef<{ destroy: () => void } | null>(null)
const restoredRef = useRef(false)
const skippedEndRef = useRef(false)
const speed = useReaderSettingsStore((s) => s.audioSpeed)
const setSpeed = useReaderSettingsStore((s) => s.setAudioSpeed)
const defaultTimer = useReaderSettingsStore((s) => s.audioTimerMinutes)
const setDefaultTimer = useReaderSettingsStore((s) => s.setAudioTimerMinutes)
const [playing, setPlaying] = useState(false)
const [cur, setCur] = useState(0)
const [dur, setDur] = useState(0)
const [rateIdx, setRateIdx] = useState(1)
const [loading, setLoading] = useState(true)
// 音轨加载失败(格式不支持 / 转码失败):界面上要给出原因,不能一直停在「加载中」
const [audioError, setAudioError] = useState('')
const [sheet, setSheet] = useState<Sheet>('none')
// 封面可能被防盗链挡掉:加载失败就退回主题色占位,不留破图
const [coverOK, setCoverOK] = useState(true)
useEffect(() => {
setCoverOK(true)
}, [cover])
// 换源/换章:重建播放器
// 定时关闭:timerLeft 为剩余秒数,0 表示未开启(legado BaseReadAloudService.timeMinute)
const [timerLeft, setTimerLeft] = useState(0)
const [timerActive, setTimerActive] = useState(false)
const [timerPreset, setTimerPreset] = useState(defaultTimer)
const timerDeadlineRef = useRef(0)
// 片头片尾草稿值(滑杆拖动中先本地预览,松手才落库)
const [creditDraft, setCreditDraft] = useState({ open: openCredits, close: closeCredits })
useEffect(() => {
setCreditDraft({ open: openCredits, close: closeCredits })
}, [openCredits, closeCredits])
// ── 换源/换章:重建播放器 ──
useEffect(() => {
const audio = audioRef.current
if (!audio) return
restoredRef.current = false
skippedEndRef.current = false
setLoading(true)
setAudioError('')
setCur(0)
setDur(0)
let cancelled = false
let hls: { destroy: () => void } | null = null
@@ -87,9 +178,57 @@ export function ReaderAudioPanel({
useEffect(() => {
const audio = audioRef.current
if (audio) audio.playbackRate = RATES[rateIdx]
}, [rateIdx])
if (audio) audio.playbackRate = speed
}, [speed])
// ── 定时关闭 ──
const applyTimer = useCallback(
(minutes: number) => {
setDefaultTimer(minutes)
setTimerPreset(minutes)
if (minutes <= 0) {
timerDeadlineRef.current = 0
setTimerLeft(0)
setTimerActive(false)
return
}
timerDeadlineRef.current = Date.now() + minutes * 60_000
setTimerLeft(minutes * 60)
setTimerActive(true)
},
[setDefaultTimer],
)
// 进场套用上次的定时设置(legado 起朗读服务时 setTimer(AppConfig.ttsTimer))
const timerBootRef = useRef(false)
useEffect(() => {
if (timerBootRef.current) return
timerBootRef.current = true
if (defaultTimer > 0) applyTimer(defaultTimer)
}, [applyTimer, defaultTimer])
useEffect(() => {
if (!timerActive) return
const id = window.setInterval(() => {
const audio = audioRef.current
// 暂停期间不倒计时(legado doDs 只在播放中扣分钟),把截止时间顺延
if (audio && audio.paused) {
timerDeadlineRef.current += 1000
return
}
const left = Math.max(0, Math.round((timerDeadlineRef.current - Date.now()) / 1000))
setTimerLeft(left)
if (left <= 0) {
timerDeadlineRef.current = 0
setTimerActive(false)
audio?.pause()
toast('定时结束,已暂停播放')
}
}, 1000)
return () => window.clearInterval(id)
}, [timerActive])
// ── 播放控制 ──
const toggle = () => {
const audio = audioRef.current
if (!audio) return
@@ -97,85 +236,502 @@ export function ReaderAudioPanel({
else audio.pause()
}
const seekBy = useCallback((delta: number) => {
const audio = audioRef.current
if (!audio) return
const total = Number.isFinite(audio.duration) ? audio.duration : audio.currentTime + Math.abs(delta)
const next = Math.min(Math.max(0, audio.currentTime + delta), Math.max(0, total - 0.2))
audio.currentTime = next
setCur(next)
}, [])
const seekTo = useCallback((seconds: number) => {
const audio = audioRef.current
if (!audio) return
audio.currentTime = seconds
setCur(seconds)
}, [])
const changeSpeed = (v: number) => {
setSpeed(v)
}
const saveCredits = (open: number, close: number) => {
setCreditDraft({ open, close })
onCreditsChange(open, close)
}
const sheetStyle = { backgroundColor: theme.bg, color: theme.text, borderColor: theme.text + '22' }
const chipClass = (active: boolean) =>
`rounded-lg border px-2.5 py-1 text-2xs font-bold transition ${active ? '' : 'opacity-70'}`
const chipStyle = (active: boolean) => ({
borderColor: active ? theme.accent : theme.text + '33',
color: active ? theme.accent : theme.text,
backgroundColor: active ? theme.accent + '1a' : 'transparent',
})
const closeSheet = () => setSheet('none')
const openSheet = (next: Sheet) => setSheet((cur) => (cur === next ? 'none' : next))
const chapterList = useMemo(
() => chapters.map((c, i) => ({ ...c, i })),
[chapters],
)
return (
<div className="flex h-full flex-col items-center justify-center gap-6 px-6 pb-16" onClick={onToggleMenu}>
{/* 封面占位(居中装饰) */}
<div
className="flex h-28 w-28 items-center justify-center rounded-full border-2"
style={{ borderColor: theme.accent + '66', color: theme.accent }}
>
<div className={playing ? 'h-16 w-16 animate-pulse rounded-full' : 'h-16 w-16 rounded-full'} style={{ backgroundColor: theme.accent + '33' }} />
</div>
<p className="max-w-full truncate px-4 text-sm font-bold" style={{ color: theme.text }}>
{title}
</p>
<p className="text-2xs opacity-60" style={{ color: theme.text }}>
{playing ? '播放中' : loading ? '加载中…' : '已暂停'} · 倍速 {RATES[rateIdx]}x
</p>
<div
className="relative flex h-full flex-col overflow-hidden"
onClick={() => {
if (sheet !== 'none') closeSheet()
else onToggleMenu()
}}
>
{/* 背景:书籍封面强模糊铺满 + 主题底色蒙层(legado AudioPlayActivity.upCover 的 ivBg)。
蒙层用主题底色但很淡:既保留日/夜主题色调,又能看清封面。 */}
{cover && coverOK && (
<>
<img
src={cover}
alt=""
aria-hidden
referrerPolicy="no-referrer"
className="pointer-events-none absolute inset-0 z-0 h-full w-full scale-110 object-cover"
style={{ filter: 'blur(32px)' }}
/>
<div
className="pointer-events-none absolute inset-0 z-0"
style={{ backgroundColor: theme.bg, opacity: 0.3 }}
/>
</>
)}
{/* 进度条 */}
<div className="flex w-full max-w-md items-center gap-2" style={{ color: theme.text }}>
<span className="w-10 text-right text-2xs tabular-nums opacity-70">{fmt(cur)}</span>
<input
type="range"
min={0}
max={Math.max(1, Math.floor(dur))}
value={Math.floor(cur)}
onChange={(e) => {
const v = Number(e.target.value)
setCur(v)
if (audioRef.current) audioRef.current.currentTime = v
}}
className="flex-1"
style={{ accentColor: theme.accent }}
/>
<span className="w-10 text-2xs tabular-nums opacity-70">{fmt(dur)}</span>
</div>
{/* transport 行(上一章 / 播放暂停 / 下一章 / 倍速) */}
<div className="flex items-center gap-6" style={{ color: theme.text }} onClick={(e) => e.stopPropagation()}>
<button type="button" onClick={onPrevChapter} className="flex items-center gap-1 text-xs font-bold opacity-80 hover:opacity-100">
<ChevronLeft size={16} /> 上一章
</button>
<button
type="button"
onClick={toggle}
className="flex h-14 w-14 items-center justify-center rounded-full text-white shadow-lg"
style={{ backgroundColor: theme.accent }}
aria-label={playing ? '暂停' : '播放'}
{/* 播放信息 + transport(抽屉打开时被抽屉压住) */}
<div className="relative z-10 flex flex-1 flex-col items-center justify-center gap-5 px-6">
{/* 封面(legado ivCover:圆形封面图;无封面/加载失败退回主题色圆点) */}
<div
className="relative flex h-32 w-32 items-center justify-center rounded-full border-2"
style={{ borderColor: theme.accent + '66', color: theme.accent }}
>
{loading ? <Loader2 size={22} className="animate-spin" /> : playing ? <Pause size={22} /> : <Play size={22} className="ml-0.5" />}
</button>
<button type="button" onClick={onNextChapter} className="flex items-center gap-1 text-xs font-bold opacity-80 hover:opacity-100">
下一章 <ChevronRight size={16} />
</button>
<div className="h-full w-full overflow-hidden rounded-full">
{cover && coverOK ? (
<img
src={cover}
alt={title}
referrerPolicy="no-referrer"
className="h-full w-full object-cover"
onError={() => setCoverOK(false)}
/>
) : (
<div className="h-full w-full" style={{ backgroundColor: theme.accent + '33' }} />
)}
</div>
<span
aria-hidden
className={`pointer-events-none absolute inset-0 rounded-full ${playing ? 'animate-pulse' : ''}`}
style={{ boxShadow: `0 0 0 6px ${theme.accent}22` }}
/>
</div>
<div className="max-w-full px-4 text-center">
<p className="truncate text-sm font-bold" style={{ color: theme.text }}>
{title}
</p>
<p className="mt-1 text-2xs opacity-60" style={{ color: theme.text }}>
{audioError ? (
<span className="font-bold" style={{ color: theme.accent }}>
{audioError}
</span>
) : (
<>
{loading ? (transcoding ? '服务端转码中,请稍候…' : '加载中…') : playing ? '播放中' : '已暂停'} ·
倍速 {speed}x
</>
)}
{!audioError && timerActive ? ` · 定时 ${fmtCountdown(timerLeft)}` : ''}
{!audioError && (openCredits > 0 || closeCredits > 0)
? ` · 跳过片头${openCredits}s/片尾${closeCredits}s`
: ''}
</p>
</div>
{/* 进度条 */}
<div className="flex w-full max-w-md items-center gap-2" style={{ color: theme.text }}>
<span className="w-10 text-right text-2xs tabular-nums opacity-70">{fmt(cur)}</span>
<input
type="range"
min={0}
max={Math.max(1, Math.floor(dur))}
value={Math.floor(cur)}
onChange={(e) => seekTo(Number(e.target.value))}
onClick={(e) => e.stopPropagation()}
className="flex-1"
style={{ accentColor: theme.accent }}
/>
<span className="w-10 text-2xs tabular-nums opacity-70">{fmt(dur)}</span>
</div>
{/* transport 行:上一章 | -15s | 播放暂停 | +15s | 下一章 */}
<div
className="flex items-center gap-5"
style={{ color: theme.text }}
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={onPrevChapter}
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
aria-label="上一章"
title="上一章"
>
<ChevronLeft size={22} />
</button>
<button
type="button"
onClick={() => seekBy(-SEEK_STEP)}
className="flex h-10 w-10 items-center justify-center rounded-full border text-2xs font-bold opacity-80 hover:opacity-100"
style={{ borderColor: theme.text + '44' }}
aria-label={`后退 ${SEEK_STEP} 秒`}
title={`后退 ${SEEK_STEP} 秒`}
>
<ChevronLeft size={12} />
<span className="text-[10px]">{SEEK_STEP}s</span>
</button>
<button
type="button"
onClick={toggle}
className="flex h-14 w-14 items-center justify-center rounded-full text-white shadow-lg"
style={{ backgroundColor: theme.accent }}
aria-label={playing ? '暂停' : '播放'}
>
{loading ? (
<Loader2 size={22} className="animate-spin" />
) : playing ? (
<Pause size={22} />
) : (
<Play size={22} className="ml-0.5" />
)}
</button>
<button
type="button"
onClick={() => seekBy(SEEK_STEP)}
className="flex h-10 w-10 items-center justify-center rounded-full border text-2xs font-bold opacity-80 hover:opacity-100"
style={{ borderColor: theme.text + '44' }}
aria-label={`前进 ${SEEK_STEP} 秒`}
title={`前进 ${SEEK_STEP} 秒`}
>
<ChevronRight size={12} />
<span className="text-[10px]">{SEEK_STEP}s</span>
</button>
<button
type="button"
onClick={onNextChapter}
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
aria-label="下一章"
title="下一章"
>
<ChevronRight size={22} />
</button>
</div>
</div>
<button
type="button"
onClick={() => setRateIdx((i) => (i + 1) % RATES.length)}
className="rounded-xl border px-3 py-1 text-2xs font-bold opacity-80 hover:opacity-100"
style={{ borderColor: theme.text + '44', color: theme.text }}
{/* 动作行:常驻底部,抽屉打开时仍可点(可在几个抽屉间直接切换) */}
<div
className="relative z-50 grid h-[76px] w-full shrink-0 grid-cols-4 px-6"
style={{ color: theme.text }}
onClick={(e) => e.stopPropagation()}
>
{RATES[rateIdx]}x
</button>
{(
[
{
icon: <ListMusic size={18} />,
label: '章节',
active: sheet === 'chapters',
action: () => openSheet('chapters'),
},
{
icon: <Timer size={18} />,
label: timerActive ? fmtCountdown(timerLeft) : '定时',
active: timerActive || sheet === 'timer',
action: () => openSheet('timer'),
},
{
icon: <Gauge size={18} />,
label: `${speed}x`,
active: speed !== 1 || sheet === 'speed',
action: () => openSheet('speed'),
},
{
icon: <Scissors size={18} />,
label: '片头片尾',
active: openCredits > 0 || closeCredits > 0 || sheet === 'credits',
action: () => openSheet('credits'),
},
] as const
).map((item) => (
<button
key={item.label}
type="button"
onClick={item.action}
className="flex flex-col items-center justify-center gap-1 opacity-80 hover:opacity-100"
style={item.active ? { color: theme.accent, opacity: 1 } : undefined}
>
{item.icon}
<span className="text-2xs tabular-nums">{item.label}</span>
</button>
))}
</div>
{/* ── 底部抽屉(到动作行上沿为止,动作行始终可点) ── */}
{sheet !== 'none' && (
<>
<button
type="button"
aria-label="关闭面板"
className={`absolute inset-x-0 top-0 z-30 cursor-default bg-black/30 ${ACTION_BAR_BOTTOM}`}
onClick={closeSheet}
/>
<div
className={`absolute inset-x-0 z-40 max-h-[65%] overflow-hidden rounded-t-2xl border-t ${ACTION_BAR_BOTTOM}`}
style={sheetStyle}
onClick={(e) => e.stopPropagation()}
>
{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"
style={{ borderColor: theme.text + '22' }}
>
<span>章节({chapters.length})</span>
<button type="button" onClick={closeSheet} className="opacity-70 hover:opacity-100">
<X size={16} />
</button>
</div>
<div className="h-[45vh] min-h-0">
<Virtuoso
data={chapterList}
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
itemContent={(_i, row) => {
const isCurrent = row.i === chapterIndex
return (
<button
type="button"
onClick={() => {
if (row.is_volume) return
closeSheet()
onSelectChapter(row.i)
}}
className={`block w-full truncate px-4 py-2.5 text-left text-xs ${
row.is_volume ? 'font-bold opacity-70' : ''
}`}
style={isCurrent ? { color: theme.accent, fontWeight: 700 } : undefined}
>
{row.title}
</button>
)
}}
/>
</div>
</div>
)}
{sheet === 'timer' && (
<div className="px-4 pb-6 pt-4">
<p className="mb-3 text-xs font-bold">定时关闭</p>
<div className="flex flex-wrap gap-2">
{AUDIO_TIMERS.map((m) => {
const active = timerActive ? timerPreset === m : m === 0
return (
<button
key={m}
type="button"
onClick={() => applyTimer(m)}
className={chipClass(active)}
style={chipStyle(active)}
>
{m === 0 ? '关闭定时' : `${m} 分钟`}
</button>
)
})}
</div>
<p className="mt-3 text-2xs opacity-60">
{timerActive ? `剩余 ${fmtCountdown(timerLeft)},播完自动暂停` : '未开启:不会自动停止播放'}
</p>
</div>
)}
{sheet === 'speed' && (
<div className="px-4 pb-6 pt-4">
<div className="mb-3 flex items-center justify-between text-xs font-bold">
<span>播放倍速</span>
<span className="tabular-nums" style={{ color: theme.accent }}>
{speed}x
</span>
</div>
<input
type="range"
min={0.5}
max={3}
step={0.1}
value={speed}
onChange={(e) => changeSpeed(Number(e.target.value))}
className="w-full"
style={{ accentColor: theme.accent }}
/>
<div className="mt-3 flex flex-wrap gap-2">
{AUDIO_SPEEDS.map((v) => (
<button
key={v}
type="button"
onClick={() => changeSpeed(v)}
className={chipClass(v === speed)}
style={chipStyle(v === speed)}
>
{v}x
</button>
))}
</div>
</div>
)}
{sheet === 'credits' && (
<div className="px-4 pb-6 pt-4">
<div className="mb-3 flex items-center justify-between text-xs font-bold">
<span>跳过片头片尾</span>
<button
type="button"
onClick={() => saveCredits(0, 0)}
className="text-2xs opacity-70 hover:opacity-100"
>
重置
</button>
</div>
<div className="space-y-4">
<div>
<div className="mb-1 flex items-center justify-between text-2xs">
<span className="opacity-70">片头</span>
<span className="tabular-nums" style={{ color: theme.accent }}>
{creditDraft.open} 秒
</span>
</div>
<input
type="range"
min={0}
max={AUDIO_CREDITS_MAX}
step={CREDIT_STEP}
value={creditDraft.open}
onChange={(e) => setCreditDraft((d) => ({ ...d, open: Number(e.target.value) }))}
onPointerUp={() => saveCredits(creditDraft.open, creditDraft.close)}
onKeyUp={() => saveCredits(creditDraft.open, creditDraft.close)}
className="w-full"
style={{ accentColor: theme.accent }}
/>
</div>
<div>
<div className="mb-1 flex items-center justify-between text-2xs">
<span className="opacity-70">片尾</span>
<span className="tabular-nums" style={{ color: theme.accent }}>
{creditDraft.close} 秒
</span>
</div>
<input
type="range"
min={0}
max={AUDIO_CREDITS_MAX}
step={CREDIT_STEP}
value={creditDraft.close}
onChange={(e) => setCreditDraft((d) => ({ ...d, close: Number(e.target.value) }))}
onPointerUp={() => saveCredits(creditDraft.open, creditDraft.close)}
onKeyUp={() => saveCredits(creditDraft.open, creditDraft.close)}
className="w-full"
style={{ accentColor: theme.accent }}
/>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{[0, 15, 30, 45, 60, 90].map((v) => (
<button
key={v}
type="button"
onClick={() => saveCredits(v, creditDraft.close)}
className={chipClass(v === creditDraft.open)}
style={chipStyle(v === creditDraft.open)}
>
片头 {v}s
</button>
))}
</div>
<p className="mt-3 text-2xs opacity-60">
每章从头播放时跳过前 {creditDraft.open} 秒;剩最后 {creditDraft.close} 秒时视为本章播完。
</p>
</div>
)}
</div>
</>
)}
<audio
ref={audioRef}
className="hidden"
preload="metadata"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onLoadedMetadata={(e) => {
setDur(e.currentTarget.duration)
onError={(e) => {
// 解码不了时浏览器只给错误码;这里把「能看懂的原因」摆到界面上。
// 服务端返回的正文说明(例如未装 ffmpeg)拿不到,只能按错误码给通用解释。
const code = e.currentTarget.error?.code
setLoading(false)
if (!restoredRef.current && initialPos > 0 && initialPos < e.currentTarget.duration) {
e.currentTarget.currentTime = initialPos
setAudioError(
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
? '浏览器无法播放该音频格式,且服务端转码不可用(请检查 ffmpeg 是否已安装)'
: code === MediaError.MEDIA_ERR_NETWORK
? '音频加载失败,请检查网络或源文件是否还在'
: '音频无法播放',
)
}}
onLoadedMetadata={(e) => {
const audio = e.currentTarget
setDur(audio.duration)
setLoading(false)
audio.playbackRate = speed
if (!restoredRef.current) {
const total = Number.isFinite(audio.duration) ? audio.duration : 0
if (initialPos > 0 && (total === 0 || initialPos < total)) {
// 有进度:按存档续播(legado: position != 0 时不套用片头)
audio.currentTime = initialPos
setCur(initialPos)
} else if (openCredits > 0 && (total === 0 || total > openCredits + 1)) {
// 全新开播:跳到片头结束位置(legado: skipStartMs)
audio.currentTime = openCredits
setCur(openCredits)
}
}
restoredRef.current = true
void e.currentTarget.play().catch(() => undefined)
void audio.play().catch(() => undefined)
}}
onTimeUpdate={(e) => {
setCur(e.currentTarget.currentTime)
onProgress(e.currentTarget.currentTime)
const audio = e.currentTarget
const t = audio.currentTime
setCur(t)
onProgress(t)
// 片尾跳过(legado upPlayProgress:durP >= duration - skipEnds 即当播完)
if (
closeCredits > 0 &&
!skippedEndRef.current &&
Number.isFinite(audio.duration) &&
audio.duration > closeCredits + 1 &&
t >= audio.duration - closeCredits
) {
skippedEndRef.current = true
if (!hasNext) {
audio.pause()
audio.currentTime = Math.max(0, audio.duration - 0.5)
return
}
onEnded()
}
}}
onEnded={onEnded}
/>