This commit is contained in:
truewhile
2026-10-29 23:47:18 +08:00
parent d820214abc
commit 623d87f12b
16 changed files with 1088 additions and 165 deletions
+16 -4
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight, Gauge, ListMusic, Loader2, Pause, Play, Scissors, Timer, X } from 'lucide-react'
import { ChevronLeft, ChevronRight, Gauge, ListMusic, Loader2, Pause, Play, RefreshCw, Scissors, Timer, X } from 'lucide-react'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { useReaderAudioStore } from '../../stores/readerAudio'
@@ -196,9 +196,21 @@ export function ReaderAudioPanel({ theme, onToggleMenu }: ReaderAudioPanelProps)
</p>
<p className="mt-1 text-2xs opacity-60" style={{ color: theme.text }}>
{audioError ? (
<span className="font-bold" style={{ color: theme.accent }}>
{audioError}
</span>
// 失败态下这行就是重试入口:服务端这一轮要十几秒才判定失败,
// 与其让用户对着转圈,不如把「为什么」和「再试一次」一起给他。
<button
type="button"
onClick={(e) => {
e.stopPropagation()
engine.retry()
}}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-2xs font-bold opacity-100"
style={{ backgroundColor: theme.accent + '1a', color: theme.accent }}
title="重新请求音源"
>
<RefreshCw size={12} />
{audioError} · 立即重试
</button>
) : (
<>
{loading ? (transcoding ? '服务端转码中,请稍候…' : '加载中…') : playing ? '播放中' : '已暂停'} ·
+39 -8
View File
@@ -22,8 +22,15 @@ import { readerCoverSrc } from '../../utils/readerCover'
const PROGRESS_SAVE_INTERVAL_MS = 5_000
/** -15s / +15s 步长(对齐面板与 legado SEEK_STEP)。 */
const SEEK_STEP = 15
/** 音源加载失败后自动重试(重新换取签名地址)的次数上限。 */
const AUDIO_RETRY_LIMIT = 2
/**
* 音源加载失败后自动重试的退避间隔(毫秒),数组长度即自动重试次数。
*
* 别调得太短:服务端媒体代理要回源换票、拉整章音频,失败一次本身就要十几秒,
* 立刻重试只会让用户对着转圈更久。第一次 3 秒、第二次 9 秒,总等待可控。
*/
const AUDIO_RETRY_DELAYS_MS = [3_000, 9_000]
/** 自动重试次数上限(与退避表的长度一致)。 */
const AUDIO_RETRY_LIMIT = AUDIO_RETRY_DELAYS_MS.length
export interface LoadChapterInput {
bookId: string
@@ -183,10 +190,23 @@ class ReaderAudioEngine {
}
toggle(): void {
// 失败态下播放键就是「重试」:重新解析本章音轨(换一张签名票)再装源,
// 而不是对着一份已经出错的 src 再调 play()。
if (useReaderAudioStore.getState().status === 'error') {
this.retry()
return
}
if (this.audio.paused) this.play()
else this.pause()
}
/** 用户手动重试:清掉自动重试计数与待执行定时器,立刻重新解析本章音轨。 */
retry(): void {
this.resetRetry()
useReaderAudioStore.setState({ status: 'loading', error: '' })
void this.reloadCurrentChapter()
}
seekBy(delta: number): void {
const total = Number.isFinite(this.audio.duration)
? this.audio.duration
@@ -482,13 +502,23 @@ class ReaderAudioEngine {
}
}
/** 退避后再重新解析本章音轨;期间保持 loading 态,不给用户闪错误。 */
/**
* 退避后再重新解析本章音轨。
*
* 期间刻意停留在 error 态而不是 loading:服务端这次请求本来就要十几秒才判定
* 失败,再挂一个转圈只会让人以为卡死。明确告诉他「几秒后自动重试」,并且这
* 行提示本身可以点,等于把等待权交回用户。
*/
private scheduleRetry(): void {
if (this.retryTimer !== null) window.clearTimeout(this.retryTimer)
useReaderAudioStore.setState({ status: 'loading', error: '' })
const delay = 800 * this.retryCount
const delay = AUDIO_RETRY_DELAYS_MS[Math.min(this.retryCount - 1, AUDIO_RETRY_LIMIT - 1)]
useReaderAudioStore.setState({
status: 'error',
error: `音源暂时取不到,${Math.round(delay / 1000)} 秒后自动重试`,
})
this.retryTimer = window.setTimeout(() => {
this.retryTimer = null
useReaderAudioStore.setState({ status: 'loading', error: '' })
void this.reloadCurrentChapter()
}, delay)
}
@@ -615,7 +645,7 @@ class ReaderAudioEngine {
audio.addEventListener('error', () => {
const code = audio.error?.code
// 服务端代理拉的是腾讯 CDN 的直链,跨境链路会偶发 502 / 中途断流。
// 服务端代理拉的是第三方 CDN 的直链,跨境链路会偶发被上游拒绝 / 中途断流。
// 浏览器对这两种情况都报 SRC_NOT_SUPPORTED(看起来像「格式不支持」),
// 实际上重新取一次章节(换一张签名票)通常就能播,所以先自动重试。
const retryable =
@@ -625,11 +655,12 @@ class ReaderAudioEngine {
this.scheduleRetry()
return
}
// 重试用完:文案要能解释「为什么播不了」,并且面板上这行本身就是重试按钮。
this.fail(
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
? '音频加载失败:服务端多次重试后仍取不到音源,请稍后再试'
? '音源暂时被上游拒绝'
: code === MediaError.MEDIA_ERR_NETWORK
? '音频加载失败,请检查网络后重试'
? '网络异常,音源没取到'
: '音频无法播放',
)
})
+6 -6
View File
@@ -129,17 +129,17 @@ export const generalSettingsGroup: SettingGroup = {
},
{
key: 'cache.images_max_size_mb',
label: '图片缓存上限 (MB)',
label: '磁盘缓存总容量上限 (MB)',
type: 'number',
hint: '海报、剧照等图片代理缓存目录 (cache/images) 的总占用上限。超过上限时先淘汰原图、再按修改时间清理最旧的缩略图;上限过小会导致旧图被淘汰后又要重新从上游/挂载的 Emby 下载。设为 0 表示不限制,默认 2000MB',
defaultValue: '2000',
placeholder: '2000',
hint: '磁盘缓存(hls、images、library-covers)的总占用上限。超过上限时严格按「转码 HLS → 图片 → 媒体库封面」的顺序淘汰最旧的文件,直到回落到上限的 80%。有声书转码缓存(reader-audio)另有 1GB 写入时淘汰,不计入这里。设为 0 表示不限制,默认 2048MB(2GB)',
defaultValue: '2048',
placeholder: '2048',
},
{
key: 'cache.images_originals_max_size_mb',
label: '原图缓存上限 (MB)',
label: '图片原图缓存上限 (MB)',
type: 'number',
hint: '原图只是生成缩略图的原料,丢了可以重新回源,因此单独给一个小配额(默认 256MB)。总量超限时原图也优先被淘汰,保证客户端实际读取的缩略图能留住。设为 0 表示不单独限制',
hint: 'originals 子目录的独立配额(默认 256MB)。原图只是生成缩略图的原料,丢了可以重新回源,因此单独给小配额;图片池被淘汰时优先删原图,保证客户端实际读取的缩略图能留住。设为 0 表示不单独限制',
defaultValue: '256',
placeholder: '256',
},