mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
754 lines
29 KiB
TypeScript
754 lines
29 KiB
TypeScript
// 听书播放引擎:模块级单例,持有真正的 <audio>(不进 React 树,也不进任何路由)。
|
||
//
|
||
// 为什么要脱离组件:音频面板原先挂在 ReaderViewPage 里,路由一变组件卸载,清理
|
||
// 逻辑就 removeAttribute('src') + load(),播放当场断掉;手机息屏、系统回收标签页
|
||
// 时更没有任何东西证明「这个页面正在播媒体」。把播放器做成常驻单例后:
|
||
// - 路由切换不影响播放;
|
||
// - Media Session 让锁屏/通知栏/耳机按键接管控制(Android Chrome 会据此判定
|
||
// 页面处于活跃媒体会话,后台存活率明显更高);
|
||
// - 定时关闭改由 timeupdate(后台播放时仍触发)+ 低频 ticker 驱动,不再依赖
|
||
// 被节流的 setInterval 来「到点暂停」。
|
||
//
|
||
// 进度上报也在这里:引擎自己知道当前书/章,离开页面、暂停、切章时都能补报一次,
|
||
// 不依赖调用方在卸载时回调。
|
||
import toast from 'react-hot-toast'
|
||
|
||
import { readerAPI, type ReaderChapter } from '../../api/reader'
|
||
import { useReaderAudioStore, resetReaderAudio } from '../../stores/readerAudio'
|
||
import { useReaderSettingsStore } from '../../stores/readerSettings'
|
||
import { readerCoverSrc } from '../../utils/readerCover'
|
||
|
||
/** 播放进度上报节流(毫秒):退出最多丢 5 秒。 */
|
||
const PROGRESS_SAVE_INTERVAL_MS = 5_000
|
||
/** -15s / +15s 步长(对齐面板与 legado SEEK_STEP)。 */
|
||
const SEEK_STEP = 15
|
||
|
||
export interface LoadChapterInput {
|
||
bookId: string
|
||
bookName: string
|
||
cover: string
|
||
chapters: ReaderChapter[]
|
||
chapterIndex: number
|
||
/** 当前章的音轨地址(已签名)。 */
|
||
track: string
|
||
transcoding: boolean
|
||
openCredits: number
|
||
closeCredits: number
|
||
/** 恢复进度(秒);0 表示从头。 */
|
||
initialPos: number
|
||
}
|
||
|
||
class ReaderAudioEngine {
|
||
private audio: HTMLAudioElement
|
||
/** hls.js 实例(只用到 destroy)。 */
|
||
private hls: { destroy: () => void } | null = null
|
||
/** 元数据就绪后是否已经套用过进度/片头跳转。 */
|
||
private restored = false
|
||
/** 本章是否已经触发过片尾跳过,避免 timeupdate 反复触发。 */
|
||
private skippedEnd = false
|
||
/** 最近一次播放位置(秒):节流窗口外补报进度时要用。 */
|
||
private lastTime = 0
|
||
private lastSavedAt = 0
|
||
/** 上一次落库的进度标识(书|章|秒),相同则不重复请求。 */
|
||
private savedKey = ''
|
||
/** 定时关闭截止时间戳(毫秒),0 表示未开启。 */
|
||
private timerDeadline = 0
|
||
/** 暂停开始的毫秒时间戳,0 表示当前在播(定时倒计时只在播放中走)。 */
|
||
private pausedAt = 0
|
||
private timerTicker: number | null = null
|
||
/** 一次性的初始进度,等 metadata 就绪后消费。 */
|
||
private pendingInitialPos = 0
|
||
/** 是否已经套用过「上次的定时设置」,只在首次加载时套一次。 */
|
||
private timerBooted = false
|
||
/** 正在拆卸音源(避免拆卸触发的 pause 事件被当成用户暂停)。 */
|
||
private detaching = false
|
||
/** 加载批次号:动态 import hls.js 期间被新的加载打断时用来丢弃旧结果。 */
|
||
private loadToken = 0
|
||
/** 预取缓存:章序号 → 已解析的音轨。命中的切章可以同步开播,不用等网络。 */
|
||
private prefetched = new Map<number, { track: string; transcoding: boolean }>()
|
||
/** 预取在途的章序号,避免同一章重复请求。 */
|
||
private prefetching = new Set<number>()
|
||
/** 预热下一章字节的隐藏播放器(只下载,从不 play())。 */
|
||
private warmer: HTMLAudioElement | null = null
|
||
/** warmer 当前装载的音轨,避免重复赋值。 */
|
||
private warmedTrack = ''
|
||
|
||
constructor() {
|
||
const audio = new Audio()
|
||
audio.preload = 'metadata'
|
||
this.audio = audio
|
||
this.bindAudio()
|
||
this.bindLifecycle()
|
||
this.bindSettings()
|
||
this.bindMediaSession()
|
||
}
|
||
|
||
// ─── 对外 API ─────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 加载并播放一章。同一本书同一章重复调用(阅读页 effect 重跑、目录同步回写)
|
||
* 会被忽略,避免把正在播的进度重置掉。
|
||
*/
|
||
loadChapter(input: LoadChapterInput): void {
|
||
const s = useReaderAudioStore.getState()
|
||
if (
|
||
s.bookId === input.bookId &&
|
||
s.chapterIndex === input.chapterIndex &&
|
||
s.track === input.track &&
|
||
(s.status === 'loading' || s.status === 'playing' || s.status === 'paused')
|
||
) {
|
||
return
|
||
}
|
||
// 换章前把上一章的最后位置补报掉(此时 store 里还是上一章的上下文)
|
||
this.commitProgress()
|
||
// 换书:预取的音轨属于上一本,全部作废
|
||
if (s.bookId !== input.bookId) this.clearPrefetch()
|
||
this.applyChapter(input)
|
||
}
|
||
|
||
/** 真正把一章装上播放器(同步部分):预取命中的续播也走这里。 */
|
||
private applyChapter(input: LoadChapterInput): void {
|
||
const chapterTitle = input.chapters[input.chapterIndex]?.title ?? ''
|
||
useReaderAudioStore.setState({
|
||
bookId: input.bookId,
|
||
bookName: input.bookName,
|
||
cover: input.cover,
|
||
chapters: input.chapters,
|
||
chapterIndex: input.chapterIndex,
|
||
chapterTitle,
|
||
track: input.track,
|
||
transcoding: input.transcoding,
|
||
openCredits: input.openCredits,
|
||
closeCredits: input.closeCredits,
|
||
hasNext: this.computeHasNext(input.chapters, input.chapterIndex),
|
||
status: 'loading',
|
||
error: '',
|
||
cur: 0,
|
||
dur: 0,
|
||
})
|
||
|
||
this.restored = false
|
||
this.skippedEnd = false
|
||
this.lastTime = 0
|
||
this.lastSavedAt = 0
|
||
this.savedKey = ''
|
||
this.pendingInitialPos = Math.max(0, input.initialPos)
|
||
|
||
// 进场套用上次的定时设置(对应 legado 起朗读服务时 setTimer(AppConfig.ttsTimer))
|
||
if (!this.timerBooted) {
|
||
this.timerBooted = true
|
||
const preset = useReaderSettingsStore.getState().audioTimerMinutes
|
||
if (preset > 0) this.applyTimer(preset)
|
||
}
|
||
|
||
void this.attachSource(input.track)
|
||
// 提前解析下一章的音轨并在后台预热字节:息屏自动续播时才能立刻接上
|
||
const nextIndex = this.neighborChapterIndex(1)
|
||
if (nextIndex !== null) this.prefetchChapter(nextIndex)
|
||
}
|
||
|
||
play(): void {
|
||
void this.audio.play().catch(() => {
|
||
// 自动播放被浏览器拦下(典型是 iOS):退回暂停态,让用户点按钮手动触发,
|
||
// 而不是一直停在「加载中」。
|
||
if (useReaderAudioStore.getState().status === 'loading') {
|
||
useReaderAudioStore.setState({ status: 'paused' })
|
||
}
|
||
})
|
||
}
|
||
|
||
pause(): void {
|
||
this.audio.pause()
|
||
// 还没触发 play 事件时(status 仍是 loading),下面的 pause 事件处理会跳过状态
|
||
// 判定;这里补一次,保证按钮不会一直转圈。
|
||
if (useReaderAudioStore.getState().status === 'loading') {
|
||
if (this.pausedAt === 0) this.pausedAt = Date.now()
|
||
useReaderAudioStore.setState({ status: 'paused' })
|
||
this.setSessionPlaybackState('paused')
|
||
}
|
||
}
|
||
|
||
toggle(): void {
|
||
if (this.audio.paused) this.play()
|
||
else this.pause()
|
||
}
|
||
|
||
seekBy(delta: number): void {
|
||
const total = Number.isFinite(this.audio.duration)
|
||
? this.audio.duration
|
||
: this.audio.currentTime + Math.abs(delta)
|
||
this.seekTo(Math.min(Math.max(0, this.audio.currentTime + delta), Math.max(0, total - 0.2)))
|
||
}
|
||
|
||
seekTo(seconds: number): void {
|
||
if (!Number.isFinite(seconds)) return
|
||
this.audio.currentTime = Math.max(0, seconds)
|
||
this.lastTime = this.audio.currentTime
|
||
useReaderAudioStore.setState({ cur: this.audio.currentTime })
|
||
this.updateSessionPosition()
|
||
}
|
||
|
||
/** 倍速改动写回 settings store,订阅回调负责应用到 <audio>。 */
|
||
setSpeed(v: number): void {
|
||
useReaderSettingsStore.getState().setAudioSpeed(v)
|
||
}
|
||
|
||
/** 片头/片尾跳过秒数:立刻生效并按书持久化。 */
|
||
setCredits(open: number, close: number): void {
|
||
useReaderAudioStore.setState({ openCredits: open, closeCredits: close })
|
||
const { bookId } = useReaderAudioStore.getState()
|
||
if (!bookId) return
|
||
readerAPI.saveAudioConfig(bookId, { open_credits: open, close_credits: close }).catch(() => undefined)
|
||
}
|
||
|
||
/** 定时关闭:0 表示关闭定时。 */
|
||
applyTimer(minutes: number): void {
|
||
useReaderSettingsStore.getState().setAudioTimerMinutes(minutes)
|
||
useReaderAudioStore.setState({ timerPreset: minutes })
|
||
if (minutes <= 0) {
|
||
this.timerDeadline = 0
|
||
this.pausedAt = 0
|
||
this.stopTimerTicker()
|
||
useReaderAudioStore.setState({ timerLeft: 0, timerActive: false })
|
||
return
|
||
}
|
||
this.timerDeadline = Date.now() + minutes * 60_000
|
||
// 暂停期间不倒计时:记下暂停起点,恢复播放时顺延截止时间
|
||
this.pausedAt = this.audio.paused ? Date.now() : 0
|
||
useReaderAudioStore.setState({ timerLeft: minutes * 60, timerActive: true })
|
||
this.startTimerTicker()
|
||
}
|
||
|
||
selectChapter(index: number): void {
|
||
void this.goToChapter(index, 0)
|
||
}
|
||
|
||
next(): void {
|
||
const i = this.neighborChapterIndex(1)
|
||
if (i === null) return
|
||
void this.goToChapter(i, 0)
|
||
}
|
||
|
||
prev(): void {
|
||
const i = this.neighborChapterIndex(-1)
|
||
if (i === null) return
|
||
void this.goToChapter(i, 0)
|
||
}
|
||
|
||
/** 完全停止并清空状态(迷你播放器的关闭按钮)。 */
|
||
stop(): void {
|
||
this.commitProgress()
|
||
this.detachSource()
|
||
this.clearPrefetch()
|
||
this.timerDeadline = 0
|
||
this.pausedAt = 0
|
||
// 下一次开播重新套用「上次的定时设置」,否则关掉再听一本书时定时不会自动开
|
||
this.timerBooted = false
|
||
this.stopTimerTicker()
|
||
this.setSessionMetadata(null)
|
||
this.setSessionPlaybackState('none')
|
||
resetReaderAudio()
|
||
}
|
||
|
||
/** 立即补报一次进度(不吃节流窗口)。 */
|
||
commitProgress(): void {
|
||
this.lastSavedAt = 0
|
||
this.persistProgress()
|
||
}
|
||
|
||
// ─── 音源装载 ─────────────────────────────────────────────────────────
|
||
|
||
private async attachSource(src: string): Promise<void> {
|
||
const token = ++this.loadToken
|
||
// 只销毁上一章的 hls,不再 removeAttribute('src') + load()。load() 会把媒体
|
||
// 元素彻底卸载、释放系统音频会话,息屏时紧接着的 play() 就会被浏览器当成
|
||
// 「无用户手势」拦下——表现正是切章卡住、解锁后必须手动点播放。直接覆盖 src,
|
||
// 元素始终持有媒体,会话才接得上。
|
||
this.hls?.destroy()
|
||
this.hls = null
|
||
if (!src) {
|
||
this.audio.removeAttribute('src')
|
||
this.audio.load()
|
||
this.setSessionMetadata(null)
|
||
this.fail('本章没有可播放的音频')
|
||
return
|
||
}
|
||
// 先停住上一章:HLS 分支要 await 动态 import,不先停会有一小段两章叠播
|
||
if (!this.audio.paused) this.audio.pause()
|
||
this.setSessionMetadata(src)
|
||
if (src.includes('.m3u8')) {
|
||
const mod = await import('hls.js')
|
||
// 动态 import 期间可能已经切到别的章:丢弃这次结果
|
||
if (token !== this.loadToken) return
|
||
const Hls = mod.default
|
||
if (Hls.isSupported()) {
|
||
const inst = new Hls({ enableWorker: true })
|
||
inst.on(Hls.Events.ERROR, (_evt, data) => {
|
||
if (data.fatal) this.fail('音频加载失败,请检查网络或源文件是否还在')
|
||
})
|
||
inst.loadSource(src)
|
||
inst.attachMedia(this.audio)
|
||
this.hls = inst
|
||
return
|
||
}
|
||
// Safari 原生 HLS
|
||
this.audio.src = src
|
||
return
|
||
}
|
||
this.audio.src = src
|
||
}
|
||
|
||
/** 彻底卸载音源(停止播放时用)。换章不走这里,见 attachSource 的说明。 */
|
||
private detachSource(): void {
|
||
this.detaching = true
|
||
this.hls?.destroy()
|
||
this.hls = null
|
||
this.audio.removeAttribute('src')
|
||
this.audio.load()
|
||
this.detaching = false
|
||
}
|
||
|
||
// ─── 预取与续播 ───────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 提前解析某章的音轨并预热字节。息屏时 ended 与 play() 之间不能有网络空档,
|
||
* 否则系统收走音频会话、标签页被冻结,浏览器就拒绝自动播放了。
|
||
*/
|
||
private prefetchChapter(index: number): void {
|
||
const { bookId, chapters } = useReaderAudioStore.getState()
|
||
if (!bookId || index < 0 || index >= chapters.length || chapters[index].is_volume) return
|
||
if (this.prefetched.has(index) || this.prefetching.has(index)) return
|
||
// 反复跳章时旧的预取会变成垃圾,做个上限兜底
|
||
if (this.prefetched.size > 32) this.prefetched.clear()
|
||
this.prefetching.add(index)
|
||
const forBook = bookId
|
||
readerAPI
|
||
.bookContent(bookId, index)
|
||
.then((ct) => {
|
||
// 期间换了书:结果作废
|
||
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 })
|
||
// 要服务端转码的音轨立刻预热(把转码提前跑掉);直链等到本章快播完再拉字节,
|
||
// 避免用户中途停下时白下整章的流量。
|
||
if (ct.transcoding) this.warmNextBytes(track)
|
||
})
|
||
.catch(() => undefined)
|
||
.finally(() => this.prefetching.delete(index))
|
||
}
|
||
|
||
/** 用隐藏播放器把下一章的字节(以及服务端转码)提前拉起来,减少切章等待。 */
|
||
private warmNextBytes(track: string): void {
|
||
// HLS 交给 hls.js 按需拉流,二次下载没有意义
|
||
if (!track || track.includes('.m3u8') || this.warmedTrack === track) return
|
||
this.warmedTrack = track
|
||
if (!this.warmer) {
|
||
const a = new Audio()
|
||
a.preload = 'auto'
|
||
this.warmer = a
|
||
}
|
||
this.warmer.src = track
|
||
}
|
||
|
||
/** 本章快播完时把下一章的字节预热起来(30 秒足够开头缓冲)。 */
|
||
private maybeWarmNext(): void {
|
||
const audio = this.audio
|
||
if (!Number.isFinite(audio.duration) || audio.duration <= 0) return
|
||
if (audio.duration - audio.currentTime > 30) return
|
||
const nextIndex = this.neighborChapterIndex(1)
|
||
if (nextIndex === null) return
|
||
const hit = this.prefetched.get(nextIndex)
|
||
if (hit) this.warmNextBytes(hit.track)
|
||
}
|
||
|
||
/** 用预取结果接续下一章;命中返回 true(未命中由调用方走异步加载兜底)。 */
|
||
private playPrefetched(index: number): boolean {
|
||
const hit = this.prefetched.get(index)
|
||
if (!hit) return false
|
||
this.prefetched.delete(index)
|
||
const s = useReaderAudioStore.getState()
|
||
this.applyChapter({
|
||
bookId: s.bookId,
|
||
bookName: s.bookName,
|
||
cover: s.cover,
|
||
chapters: s.chapters,
|
||
chapterIndex: index,
|
||
track: hit.track,
|
||
transcoding: hit.transcoding,
|
||
openCredits: s.openCredits,
|
||
closeCredits: s.closeCredits,
|
||
initialPos: 0,
|
||
})
|
||
// 立刻在 ended 的同一次事件里发起 play(),保证没有空档。片头跳转由
|
||
// loadedmetadata 处理器完成:音频在 loadedmetadata 之前不会出声,所以
|
||
// 先 play() 也不会漏出片头。HLS 交给 hls.js 承载,无法同帧开播。
|
||
if (!hit.track.includes('.m3u8')) this.play()
|
||
return true
|
||
}
|
||
|
||
private clearPrefetch(): void {
|
||
this.prefetched.clear()
|
||
this.prefetching.clear()
|
||
this.warmedTrack = ''
|
||
if (this.warmer) {
|
||
this.warmer.removeAttribute('src')
|
||
this.warmer.load()
|
||
}
|
||
}
|
||
|
||
private async goToChapter(index: number, initialPos: number): Promise<void> {
|
||
const { bookId, chapters } = useReaderAudioStore.getState()
|
||
if (!bookId || index < 0 || index >= chapters.length || chapters[index].is_volume) return
|
||
// 命中预取:直接装,不等网络(片尾自动续播大多走这里)
|
||
const hit = this.prefetched.get(index)
|
||
if (hit) {
|
||
useReaderAudioStore.setState({ status: 'loading', error: '' })
|
||
const s = useReaderAudioStore.getState()
|
||
this.loadChapter({
|
||
bookId,
|
||
bookName: s.bookName,
|
||
cover: s.cover,
|
||
chapters,
|
||
chapterIndex: index,
|
||
track: hit.track,
|
||
transcoding: hit.transcoding,
|
||
openCredits: s.openCredits,
|
||
closeCredits: s.closeCredits,
|
||
initialPos,
|
||
})
|
||
return
|
||
}
|
||
useReaderAudioStore.setState({ status: 'loading', error: '' })
|
||
try {
|
||
const ct = await readerAPI.bookContent(bookId, index)
|
||
const track = ct.tracks?.[0]
|
||
if (ct.type !== 'audio' || !track) {
|
||
this.fail('本章没有可播放的音频')
|
||
return
|
||
}
|
||
const s = useReaderAudioStore.getState()
|
||
this.loadChapter({
|
||
bookId,
|
||
bookName: s.bookName,
|
||
cover: s.cover,
|
||
chapters,
|
||
chapterIndex: index,
|
||
track,
|
||
transcoding: ct.transcoding ?? false,
|
||
openCredits: s.openCredits,
|
||
closeCredits: s.closeCredits,
|
||
initialPos,
|
||
})
|
||
} catch (e) {
|
||
this.fail((e as Error).message || '章节加载失败')
|
||
}
|
||
}
|
||
|
||
private fail(message: string): void {
|
||
useReaderAudioStore.setState({ status: 'error', error: message })
|
||
this.setSessionPlaybackState('paused')
|
||
}
|
||
|
||
// ─── <audio> 事件 ─────────────────────────────────────────────────────
|
||
|
||
private bindAudio(): void {
|
||
const audio = this.audio
|
||
|
||
audio.addEventListener('play', () => {
|
||
useReaderAudioStore.setState({ status: 'playing' })
|
||
this.setSessionPlaybackState('playing')
|
||
// 恢复播放:把暂停时长顺延到定时截止时间
|
||
if (this.pausedAt > 0) {
|
||
this.timerDeadline += Date.now() - this.pausedAt
|
||
this.pausedAt = 0
|
||
}
|
||
})
|
||
|
||
audio.addEventListener('pause', () => {
|
||
if (this.detaching) return
|
||
// 只认「播放中 -> 暂停」这一种。换源/停止时 load() 也会抛出 pause 事件,
|
||
// 那些时刻 status 是 loading/idle,直接忽略;否则会把刚进入的加载态改成暂停,
|
||
// 并给新章节写一条位置为 0 的进度。
|
||
if (useReaderAudioStore.getState().status !== 'playing') return
|
||
if (this.pausedAt === 0) this.pausedAt = Date.now()
|
||
useReaderAudioStore.setState({ status: 'paused' })
|
||
this.setSessionPlaybackState('paused')
|
||
this.commitProgress()
|
||
})
|
||
|
||
audio.addEventListener('ended', () => {
|
||
this.lastTime = Number.isFinite(audio.duration) ? audio.duration : this.lastTime
|
||
this.commitProgress()
|
||
if (useReaderAudioStore.getState().hasNext) {
|
||
// 预取命中:在 ended 的同一次事件里同步接上下一章。息屏时这段「零空档」
|
||
// 很关键——空太久系统会收走音频会话、冻结标签页,浏览器随后拒绝自动播放。
|
||
const nextIndex = this.neighborChapterIndex(1)
|
||
if (nextIndex !== null && this.playPrefetched(nextIndex)) return
|
||
this.next()
|
||
return
|
||
}
|
||
// 最后一章播完:ended 不会触发 pause,倒计时/暂停态要在这里自己落定
|
||
if (this.pausedAt === 0) this.pausedAt = Date.now()
|
||
useReaderAudioStore.setState({ status: 'paused' })
|
||
this.setSessionPlaybackState('paused')
|
||
})
|
||
|
||
audio.addEventListener('loadedmetadata', () => {
|
||
const total = Number.isFinite(audio.duration) ? audio.duration : 0
|
||
useReaderAudioStore.setState({ dur: total })
|
||
if (!this.restored) {
|
||
const { openCredits } = useReaderAudioStore.getState()
|
||
if (this.pendingInitialPos > 0 && (total === 0 || this.pendingInitialPos < total)) {
|
||
// 有进度:按存档续播(legado: position != 0 时不套用片头)
|
||
audio.currentTime = this.pendingInitialPos
|
||
useReaderAudioStore.setState({ cur: this.pendingInitialPos })
|
||
} else if (openCredits > 0 && (total === 0 || total > openCredits + 1)) {
|
||
// 全新开播:跳到片头结束位置(legado: skipStartMs)
|
||
audio.currentTime = openCredits
|
||
useReaderAudioStore.setState({ cur: openCredits })
|
||
}
|
||
this.pendingInitialPos = 0
|
||
}
|
||
this.restored = true
|
||
audio.playbackRate = useReaderAudioStore.getState().speed
|
||
this.play()
|
||
})
|
||
|
||
audio.addEventListener('durationchange', () => {
|
||
const total = Number.isFinite(audio.duration) ? audio.duration : 0
|
||
useReaderAudioStore.setState({ dur: total })
|
||
this.updateSessionPosition()
|
||
})
|
||
|
||
audio.addEventListener('timeupdate', () => {
|
||
const t = audio.currentTime
|
||
this.lastTime = t
|
||
useReaderAudioStore.setState({ cur: t })
|
||
this.updateSessionPosition()
|
||
this.reportProgress()
|
||
this.checkCreditsEnd()
|
||
// 后台播放时 timeupdate 仍会触发,这是「到点暂停」最可靠的位置
|
||
this.checkTimer()
|
||
this.maybeWarmNext()
|
||
})
|
||
|
||
audio.addEventListener('error', () => {
|
||
const code = audio.error?.code
|
||
this.fail(
|
||
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
|
||
? '浏览器无法播放该音频格式,且服务端转码不可用(请检查 ffmpeg 是否已安装)'
|
||
: code === MediaError.MEDIA_ERR_NETWORK
|
||
? '音频加载失败,请检查网络或源文件是否还在'
|
||
: '音频无法播放',
|
||
)
|
||
})
|
||
}
|
||
|
||
// ─── 页面生命周期:只负责补报进度,绝不暂停播放 ───────────────────────
|
||
|
||
private bindLifecycle(): void {
|
||
const flush = () => this.commitProgress()
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'hidden') flush()
|
||
})
|
||
window.addEventListener('pagehide', flush)
|
||
}
|
||
|
||
private bindSettings(): void {
|
||
const apply = () => {
|
||
const { audioSpeed } = useReaderSettingsStore.getState()
|
||
this.audio.playbackRate = audioSpeed
|
||
useReaderAudioStore.setState({ speed: audioSpeed })
|
||
}
|
||
apply()
|
||
// 引擎是常驻单例,订阅不需要解除
|
||
useReaderSettingsStore.subscribe(apply)
|
||
}
|
||
|
||
// ─── 进度 ─────────────────────────────────────────────────────────────
|
||
|
||
private reportProgress(): void {
|
||
const now = Date.now()
|
||
if (now - this.lastSavedAt < PROGRESS_SAVE_INTERVAL_MS) return
|
||
this.lastSavedAt = now
|
||
this.persistProgress()
|
||
}
|
||
|
||
private persistProgress(): void {
|
||
const { bookId, chapterIndex, chapterTitle } = useReaderAudioStore.getState()
|
||
if (!bookId || this.lastTime <= 0) return
|
||
const pos = Math.max(0, Math.floor(this.lastTime))
|
||
const key = `${bookId}|${chapterIndex}|${pos}`
|
||
if (key === this.savedKey) return
|
||
this.savedKey = key
|
||
readerAPI.saveProgress(bookId, { chapter_index: chapterIndex, pos, chapter_title: chapterTitle }).catch(() => undefined)
|
||
}
|
||
|
||
// ─── 片头片尾 ─────────────────────────────────────────────────────────
|
||
|
||
private checkCreditsEnd(): void {
|
||
const { closeCredits, hasNext } = useReaderAudioStore.getState()
|
||
const audio = this.audio
|
||
if (closeCredits <= 0 || this.skippedEnd) return
|
||
if (!Number.isFinite(audio.duration) || audio.duration <= closeCredits + 1) return
|
||
if (audio.currentTime < audio.duration - closeCredits) return
|
||
this.skippedEnd = true
|
||
if (!hasNext) {
|
||
audio.pause()
|
||
audio.currentTime = Math.max(0, audio.duration - 0.5)
|
||
return
|
||
}
|
||
this.next()
|
||
}
|
||
|
||
// ─── 定时关闭 ─────────────────────────────────────────────────────────
|
||
|
||
/** 剩余秒数:暂停期间以暂停起点为准,倒计时不会在暂停时溜走。 */
|
||
private timerLeftSeconds(): number {
|
||
if (this.timerDeadline === 0) return 0
|
||
const now = this.pausedAt > 0 ? this.pausedAt : Date.now()
|
||
return Math.max(0, Math.round((this.timerDeadline - now) / 1000))
|
||
}
|
||
|
||
private checkTimer(): void {
|
||
const s = useReaderAudioStore.getState()
|
||
if (!s.timerActive || this.timerDeadline === 0) return
|
||
const left = this.timerLeftSeconds()
|
||
if (left !== s.timerLeft) useReaderAudioStore.setState({ timerLeft: left })
|
||
// 暂停中不判到期:恢复播放时截止时间已经顺延过
|
||
if (left <= 0 && this.pausedAt === 0) {
|
||
this.timerDeadline = 0
|
||
this.stopTimerTicker()
|
||
useReaderAudioStore.setState({ timerLeft: 0, timerActive: false })
|
||
this.audio.pause()
|
||
toast('定时结束,已暂停播放')
|
||
}
|
||
}
|
||
|
||
private startTimerTicker(): void {
|
||
this.stopTimerTicker()
|
||
this.timerTicker = window.setInterval(() => this.checkTimer(), 1000)
|
||
}
|
||
|
||
private stopTimerTicker(): void {
|
||
if (this.timerTicker !== null) {
|
||
window.clearInterval(this.timerTicker)
|
||
this.timerTicker = null
|
||
}
|
||
}
|
||
|
||
// ─── Media Session(锁屏 / 通知栏 / 耳机按键) ────────────────────────
|
||
|
||
private session(): MediaSession | null {
|
||
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return null
|
||
return navigator.mediaSession
|
||
}
|
||
|
||
private setSessionMetadata(track: string | null): void {
|
||
const session = this.session()
|
||
if (!session || typeof MediaMetadata === 'undefined') return
|
||
if (track === null) {
|
||
session.metadata = null
|
||
return
|
||
}
|
||
const { bookName, chapterTitle, cover } = useReaderAudioStore.getState()
|
||
const coverSrc = readerCoverSrc(cover)
|
||
const artwork: MediaImage[] = coverSrc
|
||
? [{ src: new URL(coverSrc, window.location.origin).href, sizes: '512x512' }]
|
||
: []
|
||
try {
|
||
session.metadata = new MediaMetadata({
|
||
title: chapterTitle || bookName || 'MeBox 听书',
|
||
artist: bookName || 'MeBox',
|
||
album: 'MeBox 听书',
|
||
artwork,
|
||
})
|
||
} catch {
|
||
// 个别浏览器对 artwork 类型挑剔,失败不影响播放
|
||
}
|
||
}
|
||
|
||
private setSessionPlaybackState(state: MediaSessionPlaybackState): void {
|
||
const session = this.session()
|
||
if (!session) return
|
||
try {
|
||
session.playbackState = state
|
||
} catch {
|
||
// 忽略
|
||
}
|
||
}
|
||
|
||
private updateSessionPosition(): void {
|
||
const session = this.session()
|
||
if (!session || typeof session.setPositionState !== 'function') return
|
||
const { dur } = useReaderAudioStore.getState()
|
||
const position = this.audio.currentTime
|
||
if (!Number.isFinite(dur) || dur <= 0 || !Number.isFinite(position)) return
|
||
const rate = this.audio.playbackRate
|
||
try {
|
||
session.setPositionState({
|
||
duration: dur,
|
||
position: Math.min(Math.max(0, position), dur),
|
||
playbackRate: Number.isFinite(rate) && rate > 0 ? rate : 1,
|
||
})
|
||
} catch {
|
||
// position 越界等情况下浏览器会抛错,忽略
|
||
}
|
||
}
|
||
|
||
private bindMediaSession(): void {
|
||
const session = this.session()
|
||
if (!session) return
|
||
const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [
|
||
['play', () => this.play()],
|
||
['pause', () => this.pause()],
|
||
['stop', () => this.stop()],
|
||
['seekbackward', () => this.seekBy(-SEEK_STEP)],
|
||
['seekforward', () => this.seekBy(SEEK_STEP)],
|
||
['previoustrack', () => this.prev()],
|
||
['nexttrack', () => this.next()],
|
||
[
|
||
'seekto',
|
||
(details) => {
|
||
if (typeof details.seekTime === 'number') this.seekTo(details.seekTime)
|
||
},
|
||
],
|
||
]
|
||
for (const [action, handler] of handlers) {
|
||
try {
|
||
session.setActionHandler(action, handler)
|
||
} catch {
|
||
// 该动作在当前浏览器不支持时会抛错,逐个忽略
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── 工具 ─────────────────────────────────────────────────────────────
|
||
|
||
private neighborChapterIndex(dir: 1 | -1): number | null {
|
||
const { chapters, chapterIndex } = useReaderAudioStore.getState()
|
||
for (let i = chapterIndex + dir; i >= 0 && i < chapters.length; i += dir) {
|
||
if (!chapters[i].is_volume) return i
|
||
}
|
||
return null
|
||
}
|
||
|
||
private computeHasNext(chapters: ReaderChapter[], index: number): boolean {
|
||
for (let i = index + 1; i < chapters.length; i++) {
|
||
if (!chapters[i].is_volume) return true
|
||
}
|
||
return false
|
||
}
|
||
}
|
||
|
||
let engine: ReaderAudioEngine | null = null
|
||
|
||
/**
|
||
* 取全局听书引擎(首次调用时才构造,避免没听过书的应用也创建一个 <audio>)。
|
||
*/
|
||
export function getReaderAudioEngine(): ReaderAudioEngine {
|
||
if (!engine) engine = new ReaderAudioEngine()
|
||
return engine
|
||
}
|