mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 21:06:38 +08:00
优化
This commit is contained in:
@@ -1,28 +1,12 @@
|
||||
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 { ChevronLeft, ChevronRight, Gauge, ListMusic, Loader2, Pause, Play, Scissors, Timer, X } from 'lucide-react'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
|
||||
import type { ReaderChapter } from '../../api/reader'
|
||||
import {
|
||||
AUDIO_CREDITS_MAX,
|
||||
AUDIO_SPEEDS,
|
||||
AUDIO_TIMERS,
|
||||
useReaderSettingsStore,
|
||||
} from '../../stores/readerSettings'
|
||||
import { useReaderAudioStore } from '../../stores/readerAudio'
|
||||
import { AUDIO_CREDITS_MAX, AUDIO_SPEEDS, AUDIO_TIMERS } from '../../stores/readerSettings'
|
||||
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
|
||||
import { readerCoverSrc } from '../../utils/readerCover'
|
||||
import { getReaderAudioEngine } from './readerAudioEngine'
|
||||
|
||||
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
|
||||
// hls.js 播 m3u8,<audio> 播直链。
|
||||
@@ -30,35 +14,12 @@ import { readerCoverSrc } from '../../utils/readerCover'
|
||||
// 动作行:章节选择、定时关闭、倍速、跳过片头片尾。
|
||||
// 片头片尾按 legado 语义:全新开播(进度 0)时 seek 到 openCredits,播放到
|
||||
// duration - closeCredits 即等同播完自动下一章;两者都以秒计、0 表示不跳过。
|
||||
//
|
||||
// 这个组件只是一层「视图」:真正的 <audio> 与全部播放状态都在常驻的
|
||||
// readerAudioEngine 单例里,面板挂载/卸载都不影响播放(见该文件顶部说明)。
|
||||
|
||||
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
|
||||
/**
|
||||
* 立即落一次进度(不节流)。只在「用户要离开这一章」时用(切后台/关页面),
|
||||
* 把节流窗口里最后几秒补上;正常播放走 onProgress 的节流上报。
|
||||
*/
|
||||
onCommitProgress: (seconds: number) => void
|
||||
onPrevChapter: () => void
|
||||
onNextChapter: () => void
|
||||
onSelectChapter: (index: number) => void
|
||||
onEnded: () => void
|
||||
onCreditsChange: (open: number, close: number) => void
|
||||
onToggleMenu: () => void
|
||||
}
|
||||
|
||||
@@ -84,56 +45,34 @@ function fmtCountdown(sec: number): string {
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function ReaderAudioPanel({
|
||||
src,
|
||||
title,
|
||||
cover,
|
||||
theme,
|
||||
initialPos,
|
||||
openCredits,
|
||||
closeCredits,
|
||||
chapters,
|
||||
chapterIndex,
|
||||
hasNext,
|
||||
transcoding = false,
|
||||
onProgress,
|
||||
onCommitProgress,
|
||||
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)
|
||||
/** 章节抽屉的虚拟列表句柄:分组下拉靠它整段跳转。 */
|
||||
export function ReaderAudioPanel({ theme, onToggleMenu }: ReaderAudioPanelProps) {
|
||||
const engine = useMemo(() => getReaderAudioEngine(), [])
|
||||
|
||||
const title = useReaderAudioStore((s) => s.chapterTitle)
|
||||
const cover = useReaderAudioStore((s) => s.cover)
|
||||
const chapters = useReaderAudioStore((s) => s.chapters)
|
||||
const chapterIndex = useReaderAudioStore((s) => s.chapterIndex)
|
||||
const status = useReaderAudioStore((s) => s.status)
|
||||
const audioError = useReaderAudioStore((s) => s.error)
|
||||
const cur = useReaderAudioStore((s) => s.cur)
|
||||
const dur = useReaderAudioStore((s) => s.dur)
|
||||
const speed = useReaderAudioStore((s) => s.speed)
|
||||
const transcoding = useReaderAudioStore((s) => s.transcoding)
|
||||
const openCredits = useReaderAudioStore((s) => s.openCredits)
|
||||
const closeCredits = useReaderAudioStore((s) => s.closeCredits)
|
||||
const timerActive = useReaderAudioStore((s) => s.timerActive)
|
||||
const timerLeft = useReaderAudioStore((s) => s.timerLeft)
|
||||
const timerPreset = useReaderAudioStore((s) => s.timerPreset)
|
||||
|
||||
const playing = status === 'playing'
|
||||
const loading = status === 'loading'
|
||||
|
||||
const tocRef = useRef<VirtuosoHandle>(null)
|
||||
|
||||
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 [loading, setLoading] = useState(true)
|
||||
// 音轨加载失败(格式不支持 / 转码失败):界面上要给出原因,不能一直停在「加载中」
|
||||
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(() => {
|
||||
@@ -142,168 +81,22 @@ export function ReaderAudioPanel({
|
||||
// 明文 http 封面在 https 部署下会被混合内容策略拦掉,统一经后端代理下发
|
||||
const coverSrc = readerCoverSrc(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)
|
||||
// 换章时清零:否则离开页面时会把上一章的位置补报成新章节的进度
|
||||
lastTimeRef.current = 0
|
||||
let cancelled = false
|
||||
let hls: { destroy: () => void } | null = null
|
||||
|
||||
const setup = async () => {
|
||||
if (src.includes('.m3u8')) {
|
||||
const mod = await import('hls.js')
|
||||
const Hls = mod.default
|
||||
if (cancelled) return
|
||||
if (Hls.isSupported()) {
|
||||
const inst = new Hls({ enableWorker: true })
|
||||
inst.loadSource(src)
|
||||
inst.attachMedia(audio)
|
||||
inst.on(Hls.Events.ERROR, (_e, data) => {
|
||||
if (data.fatal) setLoading(false)
|
||||
})
|
||||
hls = inst
|
||||
hlsRef.current = inst
|
||||
} else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
audio.src = src // Safari 原生 HLS
|
||||
}
|
||||
} else {
|
||||
audio.src = src
|
||||
}
|
||||
}
|
||||
void setup()
|
||||
return () => {
|
||||
cancelled = true
|
||||
hls?.destroy()
|
||||
hlsRef.current = null
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
}
|
||||
}, [src])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
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) => {
|
||||
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
|
||||
if (audio.paused) void audio.play().catch(() => undefined)
|
||||
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 toggle = () => engine.toggle()
|
||||
const seekBy = useCallback((delta: number) => engine.seekBy(delta), [engine])
|
||||
const seekTo = useCallback((seconds: number) => engine.seekTo(seconds), [engine])
|
||||
const changeSpeed = (v: number) => engine.setSpeed(v)
|
||||
const applyTimer = (minutes: number) => engine.applyTimer(minutes)
|
||||
|
||||
const saveCredits = (open: number, close: number) => {
|
||||
setCreditDraft({ open, close })
|
||||
onCreditsChange(open, close)
|
||||
engine.setCredits(open, close)
|
||||
}
|
||||
|
||||
const sheetStyle = { backgroundColor: theme.bg, color: theme.text, borderColor: theme.text + '22' }
|
||||
@@ -326,14 +119,11 @@ export function ReaderAudioPanel({
|
||||
}
|
||||
// 每次打开章节抽屉都把区间下拉对齐到当前章节:上次可能停在第 3 组,
|
||||
// 续读已到第 12 组,沿用旧值会显示错误的区间。
|
||||
if (next === 'chapters') setGroupIndex(chapterGroupIndexOf(chapterIndex ?? 0, chapters.length))
|
||||
if (next === 'chapters') setGroupIndex(chapterGroupIndexOf(chapterIndex, chapters.length))
|
||||
setSheet(next)
|
||||
}
|
||||
|
||||
const chapterList = useMemo(
|
||||
() => chapters.map((c, i) => ({ ...c, i })),
|
||||
[chapters],
|
||||
)
|
||||
const chapterList = useMemo(() => chapters.map((c, i) => ({ ...c, i })), [chapters])
|
||||
|
||||
// 上千章的目录整段跳转:每 100 条一组,下拉里选区间即可(见 utils/chapterGroups.ts)。
|
||||
// 目录不足一组(≤100 章)时不显示下拉——那时一屏能扫完,多了反而是噪声。
|
||||
@@ -446,7 +236,7 @@ export function ReaderAudioPanel({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrevChapter}
|
||||
onClick={() => engine.prev()}
|
||||
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
|
||||
aria-label="上一章"
|
||||
title="上一章"
|
||||
@@ -492,7 +282,7 @@ export function ReaderAudioPanel({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNextChapter}
|
||||
onClick={() => engine.next()}
|
||||
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
|
||||
aria-label="下一章"
|
||||
title="下一章"
|
||||
@@ -602,7 +392,7 @@ export function ReaderAudioPanel({
|
||||
<Virtuoso
|
||||
ref={tocRef}
|
||||
data={chapterList}
|
||||
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
||||
initialTopMostItemIndex={Math.max(0, chapterIndex)}
|
||||
rangeChanged={(range) =>
|
||||
setGroupIndex(chapterGroupIndexOf(range.startIndex, chapterList.length))
|
||||
}
|
||||
@@ -614,7 +404,7 @@ export function ReaderAudioPanel({
|
||||
onClick={() => {
|
||||
if (row.is_volume) return
|
||||
closeSheet()
|
||||
onSelectChapter(row.i)
|
||||
engine.selectChapter(row.i)
|
||||
}}
|
||||
className={`block w-full truncate px-4 py-2.5 text-left text-xs ${
|
||||
row.is_volume ? 'font-bold opacity-70' : ''
|
||||
@@ -764,71 +554,6 @@ export function ReaderAudioPanel({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
className="hidden"
|
||||
preload="metadata"
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onError={(e) => {
|
||||
// 解码不了时浏览器只给错误码;这里把「能看懂的原因」摆到界面上。
|
||||
// 服务端返回的正文说明(例如未装 ffmpeg)拿不到,只能按错误码给通用解释。
|
||||
const code = e.currentTarget.error?.code
|
||||
setLoading(false)
|
||||
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 audio.play().catch(() => undefined)
|
||||
}}
|
||||
onTimeUpdate={(e) => {
|
||||
const audio = e.currentTarget
|
||||
const t = audio.currentTime
|
||||
lastTimeRef.current = t
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user