Compare commits

...

4 Commits

Author SHA1 Message Date
truewhile b50cb7c31e 优化 2026-10-08 23:43:28 +08:00
truewhile 5f4b613bde 修复 115 云转码播放中途断流后直接掉到本地转码
115 云 HLS 会话只存在服务端内存里:服务重新部署、会话过期(30 分钟无活动)、
上游分片地址失效,都会让播放中的客户端在后续分片上拿到 404/410。原实现此时
直接退回本地转码,而低配宿主机本地转码追不上播放速度(实测 2 vCPU 只有
0.62x),用户看到的就是播放报废。

- 云 HLS 中途 fatal 时先重建会话续播(10 分钟内最多 3 次),仍失败才退回本地
- manifestLoadingMaxRetry 1 -> 4:master.m3u8 每次都要现调 115 接口,一次抖动
  不该直接判死刑
- 播放列表重写改用上游地址哈希做 key:原来用自增序号,每次重写 entries 就膨胀
  一轮、分片编号漂移(实测 762 段列表重写几次后 key 涨到 e2288..e3049)
- 会话条目数加上限,异常播放列表不会无限占用内存
- 客户端断开(seek / 切清晰度取消在途请求)不再记成 502,改记 499 与 nginx
  对齐;会话失效返回带 code 的 404/410,便于前端判断需要重建会话
- 修正进度/时长上报:云 HLS 时间轴起点恒为 0,不应再叠加 hlsStartSec(历史上
  把 7624s 的片子记成 8346s 并标成「已看完」)
- 切回直连 / 原画时保住播放位置:HLS 卸载会把 currentTime 归零,必须在切换前
  先记下目标位置
2026-10-08 22:52:31 +08:00
truewhile 67ef9c95be 优化 2026-10-08 22:18:16 +08:00
truewhile 2bf1b27e23 优化翻页 2026-10-08 20:53:39 +08:00
7 changed files with 663 additions and 103 deletions
@@ -0,0 +1,58 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/service"
)
// 云 HLS 分片代理的错误映射:
// - 会话不存在/过期要给前端可判断的 code,前端据此重拉 master.m3u8 建会话续播,
// 而不是当成播放失败直接退回本地转码;
// - 客户端主动断开(seek、切清晰度、关页面取消在途请求)不是网关故障,记 499,
// 与 nginx 的口径一致。以前统一记 502,既污染监控又会让前端拿到误导性错误。
func newCloud115HLSSessionTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
svc := &service.Container{
Cloud115: service.NewCloud115PlaybackService(&config.Config{}, zap.NewNop(), nil, nil),
}
router := gin.New()
router.GET("/api/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
return router
}
func TestCloud115HLSSessionUnknownSessionReturnsCodedNotFound(t *testing.T) {
router := newCloud115HLSSessionTestRouter()
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "hls_session_not_found") {
t.Fatalf("expected machine-readable code in body, got %s", rec.Body.String())
}
}
func TestCloud115HLSSessionClientAbortReturns499(t *testing.T) {
router := newCloud115HLSSessionTestRouter()
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != 499 {
t.Fatalf("status = %d, want 499 (client closed request); body=%s", rec.Code, rec.Body.String())
}
}
+13 -2
View File
@@ -1,6 +1,7 @@
package handler
import (
"context"
"errors"
"net/http"
"strconv"
@@ -136,11 +137,21 @@ func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
if err == nil || c.Writer.Written() {
return
}
// 客户端主动断开(播放器 seek、切切清晰度、关页面时取消在途请求)会让
// 上游请求以 context.Canceled 结束。这是正常现象,不是网关故障;以前
// 统一记成 502,既污染监控又和 nginx 的 499 对不上,这里按 499 记。
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
c.Request.Context().Err() != nil {
c.AbortWithStatus(499)
return
}
switch {
case errors.Is(err, service.ErrCloud115HLSSessionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "hls session not found"})
// code 供前端判断「会话没了,需要重新拉一次 master.m3u8 建会话」,
// 而不是直接放弃云端播放退回本地转码。
c.JSON(http.StatusNotFound, gin.H{"code": "hls_session_not_found", "error": "hls session not found"})
case errors.Is(err, service.ErrCloud115HLSUpstreamExpired):
c.JSON(http.StatusGone, gin.H{"error": "hls upstream expired"})
c.JSON(http.StatusGone, gin.H{"code": "hls_session_expired", "error": "hls upstream expired"})
default:
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
+29 -4
View File
@@ -2,6 +2,8 @@ package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -12,6 +14,8 @@ import (
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
@@ -24,6 +28,10 @@ const (
cloud115HLSSessionTTL = 30 * time.Minute
cloud115HLSMaxSessions = 256
cloud115HLSMaxManifest = 8 << 20
// cloud115HLSMaxEntries 单个会话最多记住多少个上游地址。key 由上游地址
// 派生(见 cloud115HLSKeyForURL),所以条目数只随「实际出现过的地址」增长,
// 正常一部片子就是几千条;这个上限只是防御异常/恶意播放列表的兜底。
cloud115HLSMaxEntries = 20000
)
type cloud115HLSSession struct {
@@ -35,7 +43,17 @@ type cloud115HLSSession struct {
mu sync.Mutex
entries map[string]string
next int
}
// cloud115HLSKeyForURL 由上游地址派生稳定的分片 key。
//
// 早期实现用自增序号(e1、e2…)在每次重写播放列表时重新编号:同一分片每次
// 重写都会拿到新 key,entries 随重写次数线性膨胀(实测一个 762 段的列表反复
// 重写后 key 已经涨到 e2288…e3049),客户端拿到的分片编号也会无谓漂移。
// 改成地址哈希后,同一分片在任何一次重写里都是同一个 key。
func cloud115HLSKeyForURL(upstream string) string {
sum := sha256.Sum256([]byte(upstream))
return hex.EncodeToString(sum[:12])
}
// Cloud115HLSProxy 把 115 云端 HLS 转成 MeBox 同源 HLS。
@@ -302,12 +320,19 @@ func (p *Cloud115HLSProxy) rewriteManifest(session *cloud115HLSSession, text, ba
}
func (p *Cloud115HLSProxy) proxyURL(session *cloud115HLSSession, upstream, rawQuery string) string {
key := cloud115HLSKeyForURL(upstream)
session.mu.Lock()
session.next++
key := "e" + strconv.Itoa(session.next)
session.entries[key] = upstream
_, known := session.entries[key]
if !known && len(session.entries) < cloud115HLSMaxEntries {
session.entries[key] = upstream
known = true
}
mediaID := session.MediaID
session.mu.Unlock()
if !known && p != nil && p.service != nil && p.service.log != nil {
p.service.log.Warn("cloud115 hls: session entry cap reached, segment will not resolve",
zap.String("session", session.ID), zap.Int("cap", cloud115HLSMaxEntries))
}
query := childProxyQuery(rawQuery, mediaID)
return "/api/cloud115/hls/" + url.PathEscape(session.ID) + "/" + url.PathEscape(key) + "?" + query
+30 -4
View File
@@ -25,7 +25,8 @@ func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
if strings.Contains(rewritten, "cpats01.115.com") {
t.Fatalf("upstream URL leaked into rewritten manifest: %s", rewritten)
}
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/e1?") {
wantKey := cloud115HLSKeyForURL("https://cpats01.115.com/a.m3u8?u=1&se=2")
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/"+wantKey+"?") {
t.Fatalf("proxy URL missing: %s", rewritten)
}
if !strings.Contains(rewritten, "media_id=media-1") || !strings.Contains(rewritten, "token=t") {
@@ -36,6 +37,32 @@ func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
}
}
func TestCloud115HLSProxyRewriteIsStableAcrossRewrites(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
entries: map[string]string{},
}
manifest := "#EXTM3U\n#EXT-X-TARGETDURATION:10\n" +
"https://cpats01.115.com/seg0.ts?x=0\nhttps://cpats01.115.com/seg1.ts?x=1\n"
first := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "media_id=media-1")
entriesAfterFirst := len(session.entries)
second := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "media_id=media-1")
// 同一分片在每次重写里都必须是同一个 key,客户端拿到的地址才不会无谓漂移。
if first != second {
t.Fatalf("rewrite is not stable:\nfirst = %s\nsecond = %s", first, second)
}
if len(session.entries) != entriesAfterFirst {
t.Fatalf("entries grew on rewrite: %d -> %d", entriesAfterFirst, len(session.entries))
}
if entriesAfterFirst != 2 {
t.Fatalf("session entries = %d, want 2", entriesAfterFirst)
}
}
func TestCloud115HLSProxyRewriteKeyURI(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
@@ -151,7 +178,6 @@ func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
MediaID: "media-1",
ExpiresAt: time.Now().Add(time.Hour),
entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"},
next: 1,
}
proxy.sessions[session.ID] = session
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
@@ -162,7 +188,8 @@ func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/e2?") {
wantKey := cloud115HLSKeyForURL("https://cpats01.115.com/seg.ts?x=1")
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/"+wantKey+"?") {
t.Fatalf("rewritten variant missing proxy segment: %s", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "cpats01.115.com") {
@@ -192,7 +219,6 @@ func TestCloud115HLSProxyServeChildStreamsRange(t *testing.T) {
MediaID: "media-1",
ExpiresAt: time.Now().Add(time.Hour),
entries: map[string]string{"e1": "https://cpats01.115.com/seg.ts?x=1"},
next: 1,
}
proxy.sessions[session.ID] = session
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
+9 -1
View File
@@ -33,6 +33,12 @@ interface HorizontalSwipeOptions {
dominance?: number
/** 手势超过这个时长不算滑动(毫秒)。只对非跟手的滑动判定生效。 */
maxDuration?: number
/**
* 跟手拖拽开始(方向判定通过、第一次 onDrag 之前)调一次。
* 宿主在这里记下跟手层当前的实际位移:上一次翻页的归位动画可能还没滑完,
* 新手势要接着那点残余位移继续,否则中途接管会把残余一笔抹掉,画面跳一格。
*/
onDragStart?: () => void
/** 跟手拖拽中的位移(px,向右为正)。传了才启用跟手。 */
onDrag?: (dx: number) => void
/** 跟手松手:宿主在这里回弹或翻页(随后浏览器补发的 click 会被吞掉)。 */
@@ -54,6 +60,7 @@ export function useHorizontalSwipe({
minDistance = 45,
dominance = 1.2,
maxDuration = 800,
onDragStart,
onDrag,
onDragEnd,
}: HorizontalSwipeOptions): HorizontalSwipeHandlers {
@@ -88,11 +95,12 @@ export function useHorizontalSwipe({
return
}
draggingRef.current = true
onDragStart?.()
}
lastDxRef.current = dx
onDrag?.(dx)
},
[onDrag, dominance],
[onDragStart, onDrag, dominance],
)
const onTouchEnd = useCallback(
+106 -11
View File
@@ -81,6 +81,10 @@ type PlaybackProgressSession = {
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
const SKIP_NOTICE_MS = 6000
// 115 云 HLS 中途断流后,允许在窗口内重建几次会话再退回本地转码。
const CLOUD_HLS_RELOAD_MAX = 3
const CLOUD_HLS_RELOAD_WINDOW_MS = 10 * 60 * 1000
function normalizePlayerVolume(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 1
@@ -185,6 +189,17 @@ export function PlayerPage() {
const cloudPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const cloudRetryRef = useRef(5)
const pendingSeekRef = useRef<number | null>(null)
// 云 HLS 会话重建计数(时间戳)。115 云转码的会话只存在服务端内存里:服务
// 重新部署、会话过期(30 分钟无活动)、上游地址失效都会让播放中途拿到
// 404/410。这种情况下重新拉一次 master.m3u8 建个新会话即可续播,没必要直接
// 掉到本地转码(这台机器本地转码只有 0.6x,等于播放报废)。窗口内限制次数,
// 避免上游真的坏了时无限重连。
const cloudReloadsRef = useRef<number[]>([])
const [cloudReloadNonce, setCloudReloadNonce] = useState(0)
// 切回直连/原画时把当前位置带过去:HLS 卸载会把 video.currentTime 归零,
// 必须在切换前先记下来,等直连 loadedmetadata 后再跳。
const pendingDirectStartRef = useRef(0)
const hlsSourceRef = useRef<'cloud' | 'local'>('local')
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
const [danmakuOpen, setDanmakuOpen] = useState(false)
@@ -488,6 +503,8 @@ export function PlayerPage() {
setCloudWaitMessage('')
setCloudWaitStartedAt(0)
pendingSeekRef.current = null
cloudReloadsRef.current = []
pendingDirectStartRef.current = 0
if (cloudPollTimerRef.current) {
clearTimeout(cloudPollTimerRef.current)
cloudPollTimerRef.current = null
@@ -953,6 +970,38 @@ export function PlayerPage() {
[playbackInfo, setPlaybackMode],
)
/**
* 115 云 HLS 中途断流后重建会话。
*
* 115 的云端转码会话只存在服务端内存里:服务重新部署、会话到期(30 分钟无活动)、
* 上游分片地址失效,都会让正在播放的客户端在后续分片上拿到 404/410。重新请求一次
* master.m3u8 就会建出新会话,从当前位置继续播即可——比直接退回本地转码好得多
* (低配宿主机本地转码往往追不上播放速度,等于不能看)。
*
* 窗口内超过次数上限返回 false,由调用方走本地转码兜底,避免上游真的坏了时无限重连。
*/
const reloadCloudHls = useCallback(
(position: number): boolean => {
const now = Date.now()
const recent = cloudReloadsRef.current.filter((at) => now - at < CLOUD_HLS_RELOAD_WINDOW_MS)
if (recent.length >= CLOUD_HLS_RELOAD_MAX) {
cloudReloadsRef.current = recent
return false
}
recent.push(now)
cloudReloadsRef.current = recent
// 云 HLS 是整条时间轴,起播后按绝对位置跳一下,别让用户回到片头。
pendingSeekRef.current = position > 2 ? position : null
setCloudWaiting(false)
setCloudWaitMessage('')
setHlsSource('cloud')
setPlaybackMode('hls')
setCloudReloadNonce((v) => v + 1)
return true
},
[setPlaybackMode],
)
const startCloudTranscode = useCallback(
async (definition: number) => {
if (!mediaId) return
@@ -1126,7 +1175,10 @@ export function PlayerPage() {
// hosts and remote STRM sources regularly need more than hls.js's
// 10s default, which otherwise aborts a healthy transcode.
manifestLoadingTimeOut: 60_000,
manifestLoadingMaxRetry: 1,
// 115 云 HLS 的 master.m3u8 每次都要现调 115 接口(实测 0.5–1.3s),
// 还可能撞上 409「正在转码」。只重试 1 次的话,一次抖动就判死刑,
// 直接掉到本地转码。
manifestLoadingMaxRetry: 4,
})
try {
video.currentTime = 0
@@ -1160,6 +1212,11 @@ export function PlayerPage() {
if (data.fatal) {
if (hlsSource === 'cloud') {
const position = ref.current?.currentTime || 0
// 先尝试重建云会话续播;只有连着失败到上限才退回本地转码。
if (reloadCloudHls(position)) {
toast('115 云端播放中断,正在重新连接…', { duration: 2200 })
return
}
setHlsUnavailable(false)
switchToLocalHLS(position)
toast.error('115 云端播放失败,切换本地转码')
@@ -1211,12 +1268,24 @@ export function PlayerPage() {
if (video.src !== absoluteURL) {
directRetryRef.current = false
clearFallbackTimer()
// 进出 VR 会切换播放源地址,这里保住当前播放位置。
const resumeAt = video.currentTime > 2 ? video.currentTime : 0
// 保住当前位置。两个来源:显式切回直连/原画时提前记下的目标位置
// (HLS 卸载会把 video.currentTime 归零,所以不能只读 video.currentTime),
// 以及 VR 进出时切换播放源地址的情形。
const resumeAt = Math.max(
pendingDirectStartRef.current,
video.currentTime > 2 ? video.currentTime : 0,
)
pendingDirectStartRef.current = 0
if (resumeAt > 0) {
video.addEventListener(
'loadedmetadata',
() => {
// STRM/115 直链要等 seekable 覆盖目标位置再写 currentTime(否则会被
// 钳回 0),并顺带打开误报宽限期,避免这次跳转被当成播放失败又弹回 HLS。
if (isStrmMedia(currentMedia)) {
seekStrmDirectTo(resumeAt)
return
}
try {
video.currentTime = resumeAt
} catch {
@@ -1243,6 +1312,7 @@ export function PlayerPage() {
}, [
activeBurnedSubtitleStream,
clearFallbackTimer,
cloudReloadNonce,
cloudWaiting,
hlsSource,
hlsUnavailable,
@@ -1250,6 +1320,8 @@ export function PlayerPage() {
mediaId,
mode,
playbackProvider,
reloadCloudHls,
seekStrmDirectTo,
selectedQuality,
setPlaybackMode,
switchToLocalHLS,
@@ -1398,8 +1470,9 @@ export function PlayerPage() {
const modeRef = useRef(mode)
useEffect(() => {
hlsStartSecRef.current = hlsStartSec
hlsSourceRef.current = hlsSource
modeRef.current = mode
}, [hlsStartSec, mode])
}, [hlsStartSec, hlsSource, mode])
// Persist resume position every 10 seconds while playing, and immediately upon
// pause/page hide/unmount. Bind after mediaId is available because
@@ -1412,16 +1485,21 @@ export function PlayerPage() {
lastSentRef.current = 0
}
// 本地 HLS 的播放列表 t=0 对应 hlsStartSec,云 HLS 的播放列表本身就是整条
// 时间轴(起点恒为 0)。以前两种模式都叠加 hlsStartSec:只要本地转码残留过
// 一个非 0 起点再切到云端,上报的进度和总时长就会被整体抬高(实测把 7624s
// 的片子记成 8346s 并直接标成「已看完」)。
const timelineOffsetSec = () =>
modeRef.current === 'hls' && hlsSourceRef.current === 'local' ? hlsStartSecRef.current : 0
const absolutePositionMs = () => {
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
const absolute = currentStartSec + (video.currentTime || 0)
const absolute = timelineOffsetSec() + (video.currentTime || 0)
return Number.isFinite(absolute) ? Math.max(0, Math.floor(absolute * 1000)) : 0
}
const absoluteDurationMs = () => {
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
const mediaDur = mediaRef.current?.duration_sec || 0
const streamDur = Number.isFinite(video.duration) ? video.duration : 0
const totalSec = Math.max(mediaDur, currentStartSec + streamDur)
const totalSec = Math.max(mediaDur, timelineOffsetSec() + streamDur)
return Number.isFinite(totalSec) && totalSec > 0 ? Math.floor(totalSec * 1000) : 0
}
const send = (keepalive: boolean) => {
@@ -1782,16 +1860,27 @@ export function PlayerPage() {
toast('该媒体为直连播放,无需且不支持转码')
return
}
const video = ref.current
// HLS 卸载会把 video.currentTime 归零,所以先按当前模式换算成绝对秒数记下来。
const position = video
? mode === 'hls' && hlsSource === 'local'
? hlsStartSec + video.currentTime
: video.currentTime
: 0
const next = mode === 'hls' ? 'direct' : 'hls'
if (next === 'hls') {
// 带着当前位置切过去:云 HLS 跳一下、本地 HLS 从该点重开转码,都不从头播。
enterHlsPlayback(ref.current?.currentTime || 0)
enterHlsPlayback(position)
} else {
setCloudWaiting(false)
// 直连要等 loadedmetadata 再跳(见播放方式接线 effect),这里先把目标位置存下来。
pendingDirectStartRef.current = position
}
setPlaybackMode(next)
}, [
enterHlsPlayback,
hlsSource,
hlsStartSec,
isDirectStream,
mode,
setPlaybackMode,
@@ -1989,16 +2078,22 @@ export function PlayerPage() {
const selectPlaybackQuality = useCallback(
(quality: PlaybackQuality) => {
const video = ref.current
const position = video?.currentTime || 0
const localTime = video?.currentTime || 0
// 云 HLS 的 currentTime 本来就在绝对时间轴上;本地 HLS 的 t=0 对应 hlsStartSec。
const position = mode === 'hls' && hlsSource === 'local' ? hlsStartSec + localTime : localTime
if (quality.source === 'original') {
setCloudWaiting(false)
setHlsSource('local')
// 切回原始文件直连:位置由直连分支的 loadedmetadata 跳转负责带过去。
pendingDirectStartRef.current = position
setPlaybackMode('direct')
return
}
if (quality.source === 'cloud') {
setSelectedQuality(quality.id)
setHlsSource('cloud')
// 用户手动选了云端档位:这是一次明确的重新尝试,重置自动重连的次数窗口。
cloudReloadsRef.current = []
if (quality.available) {
setCloudWaiting(false)
setCloudWaitMessage('')
@@ -2025,7 +2120,7 @@ export function PlayerPage() {
if (position > 2) setHlsStartSec(position)
setPlaybackMode('hls')
},
[setPlaybackMode, startCloudTranscode],
[hlsSource, hlsStartSec, mode, setPlaybackMode, startCloudTranscode],
)
const changeSubtitleChineseMode = useCallback(
+418 -81
View File
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { useParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import {
@@ -44,6 +44,19 @@ import { getReaderAudioEngine } from './readerAudioEngine'
const COLUMN_GAP = 48
/**
* 正文「保持真实渲染」的页窗口:当前页 ± 这么多页的段落不做 content-visibility 跳过。
*
* 为什么要有这个窗口:被跳过的段落不能每帧重算可见性。翻页/拖动时内容每移动一点,
* 进入或离开视口的段落都要重新判定,而每次判定变化都会重排整条多栏流——实测一次
* 滑动触发 32 次 layout(20 次滑动主线程 10.6s,JS 只占 0.014s),帧根本画不完,
* 表现出来就是两页之间抖。窗口固定住以后,动画期间不再有可见性变化,帧就稳了;
* 只有读完窗口(每 TEXT_WARM_SHIFT 页)才付一次重排。
*/
const TEXT_WARM_PAGES = 12
/** 页码漂出这个范围才移动渲染窗口,避免每翻一页都改可见性触发重排。 */
const TEXT_WARM_SHIFT = 6
/** 正文里的图片占位行前缀(本地 EPUB 的图片,服务端已换成签名地址)。 */
const IMG_MARK = '[img]'
@@ -109,9 +122,17 @@ const ReaderTextPageBody = memo(function ReaderTextPageBody({
<>
{blocks.map((block, key) => {
const { text, comments } = block
// content-visibility:跳过视口外段落的绘制,否则每次 transform 都要重绘整章
// (实测可到数百毫秒)。默认全部按需跳过;当前页附近的段落由
// applyParagraphWarmWindow 改成 visible 并固定成一个窗口——被跳过的段落
// 会在每帧重新判定可见性,导致动画期间反复重排(见 TEXT_WARM_PAGES)。
const skipPaint: CSSProperties = {
contentVisibility: 'auto',
containIntrinsicSize: 'auto 4em',
}
if (text.startsWith(IMG_MARK)) {
return (
<p key={key} style={{ marginBottom: paragraphSpacing, textAlign: 'center' }}>
<p key={key} style={{ marginBottom: paragraphSpacing, textAlign: 'center', ...skipPaint }}>
<img
src={text.slice(IMG_MARK.length)}
alt=""
@@ -129,6 +150,7 @@ const ReaderTextPageBody = memo(function ReaderTextPageBody({
textIndent: text ? '2em' : undefined,
marginBottom: paragraphSpacing,
textAlign: standalone && comments.every((c) => c.block) ? 'center' : undefined,
...skipPaint,
}}
>
{text}
@@ -179,17 +201,59 @@ function firstReadableIndex(chapters: ReaderChapter[]): number {
* 读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;与此同时页码 +1/-1,
* ReaderComic 那边单元格的 left 偏移在同一次提交里跟着换,两者相抵,
* 屏幕上看到的就是新页从手指离开的位置平滑归位。
*
* base 是手势开始时舞台已经带着的残余位移(上一次归位动画没滑完就又被接管)。
*/
function settleComicDrag(el: HTMLDivElement, turn: 1 | -1, dx: number, turnPage: () => void): void {
function settleComicDrag(el: HTMLDivElement, turn: 1 | -1, dx: number, base: number, turnPage: () => void): void {
const width = el.clientWidth || 1
el.style.transition = 'none'
el.style.transform = `translateX(${turn > 0 ? width + dx : -width + dx}px)`
el.style.transform = `translateX(${base + dx + turn * width}px)`
void el.offsetWidth
el.style.transition = PAGE_TRANSITION
el.style.transform = 'translateX(0px)'
turnPage()
}
/**
* 读元素当前的实际横向位移。
*
* 跟手层/漫画舞台的归位是 CSS 过渡,一次手势可能在过渡中途就被下一次手势接管。
* 那时元素的 transform 停在中间值,必须把它读出来当作新手势的起点,
* 否则新手势从 dx 重新开始,残余位移被抹掉,画面就会跳一格——快速连续翻页时
* 最明显,看起来就是两页之间来回抖。
*/
function readTranslateX(el: HTMLElement): number {
const t = getComputedStyle(el).transform
if (!t || t === 'none') return 0
const nums = t.match(/-?[\d.]+(?:e[-+]?\d+)?/gi)
if (!nums) return 0
// translate3d 在 Chrome 里可能给 matrix(a,b,c,d,tx,ty) 也可能给 matrix3d(...),
// 两种都要取横向平移分量。
const v = nums.length >= 16 ? Number(nums[12]) : nums.length >= 6 ? Number(nums[4]) : 0
return Number.isFinite(v) ? v : 0
}
/**
* 文本滑动松手后的跟手层归位。正文多栏已瞬移,这里只把跟手层从「画面连续」的
* 位移滑回 0。不用 void offsetWidth 强刷布局——长章多栏一强制回流就要几百毫秒。
* turn=1 下一页,turn=-1 上一页;dx 为松手时的跟手位移。
*
* 起点必须是 base + dx + turn*stride:
* - stride 要和正文那一跳用的步长完全一致(列宽 + 列间距)。用列宽代替步长的话,
* 松手瞬间跟手层会少补一个列间距,画面先往回弹 48px 再往前滑。
* - base 是手势开始时跟手层已经带着的残余位移:上一次归位动画没滑完就又滑了一次时,
* 不带上它就等于把这段残余抹掉,画面跳一格。快速连续翻页时最明显。
*/
function settleTextDrag(dragEl: HTMLDivElement, turn: 1 | -1, dx: number, stride: number, base: number): void {
const step = stride > 0 ? stride : dragEl.clientWidth || 1
dragEl.style.transition = 'none'
dragEl.style.transform = `translate3d(${base + dx + turn * step}px,0,0)`
requestAnimationFrame(() => {
dragEl.style.transition = 'transform 140ms ease-out'
dragEl.style.transform = 'translate3d(0,0,0)'
})
}
export default function ReaderViewPage() {
const { bookId = '' } = useParams()
const settings = useReaderSettingsStore()
@@ -232,16 +296,35 @@ export default function ReaderViewPage() {
const viewportRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
/** 文本跟手层:滑动动画也写在这一层,避免给整章多栏做 transition。 */
const pageDragRef = useRef<HTMLDivElement>(null)
const [page, setPage] = useState(0)
const [pageCount, setPageCount] = useState(1)
const [vw, setVw] = useState(0)
// 文本翻页位移走命令式 transform(见 applyTextPageTransform):先改 DOM 再 setPage,
// 避免长章重渲染把翻页动画堵在主线程后面。下面几个 ref 用来去重 / 换章时关掉过渡。
// 文本翻页:位移与页脚先走 DOM,React 的 page state 等到过渡结束后再同步。
// 若在同一次点击/触摸里同步 setPage,整页重渲染会堵住下一次绘制,
// 命令式 transform 也要等卡完才开始——点按和滑动都会顿一下。
const vwRef = useRef(0)
const pageRef = useRef(0)
const pageCountRef = useRef(1)
const chapterTitleRef = useRef('')
const pageLabelRef = useRef<HTMLDivElement>(null)
const appliedTextPageRef = useRef(-1)
const appliedTextVwRef = useRef(-1)
const snapTextPageRef = useRef(false)
/** 把 pageRef 同步回 React state 的防抖定时器;翻页热路径上绝不同步 setPage。 */
const textPageSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** 首次分页完成后锁住 pageCount,避免 content-visibility 改变 scrollWidth 后反复 setPageCount。 */
const pageCountLockedRef = useRef(false)
/** 正文段落节点与它们各自落在的页号,用来算「当前页附近的渲染窗口」。 */
const paragraphsRef = useRef<NodeListOf<HTMLParagraphElement> | null>(null)
const paragraphColsRef = useRef<Int32Array | null>(null)
/** 每个段落当前是不是「真实渲染」(1=visible,0=按需跳过),只在分类变化时写 DOM。 */
const paragraphWarmRef = useRef<Uint8Array | null>(null)
/** 当前窗口中心页;-1 表示窗口失效(映射刚重算过),下次应用时重建。 */
const warmCenterRef = useRef(-1)
vwRef.current = vw
pageCountRef.current = pageCount
// 音频/漫画媒体状态
const [media, setMedia] = useState<ReaderChapterContent | null>(null)
const [restorePos, setRestorePos] = useState(0) // 音频秒数 / 漫画图片序号
@@ -410,6 +493,8 @@ export default function ReaderViewPage() {
// 换章瞬移到目标页,不要带着上一章的翻页过渡滑过去。
snapTextPageRef.current = true
appliedTextPageRef.current = -1
pageCountLockedRef.current = false
pageRef.current = 0
setPage(0)
setComicPage(0)
setCurrentImage(0)
@@ -468,63 +553,281 @@ export default function ReaderViewPage() {
}, [chapterIndex, book, chapters, contentCacheKey, contentReloadKey])
// ── 分页排版(CSS 多栏 + 平移) ──
/** 一页正文占的横向步长:列宽 + 列间距。分页位移和跟手补位必须用同一个值。 */
const textPageStride = useCallback(() => {
const width = vwRef.current > 0
? vwRef.current
: Math.round(viewportRef.current?.getBoundingClientRect().width ?? 0)
return Math.max(width, 1) + COLUMN_GAP
}, [])
/** 把正文列平移到指定页。animate=true 时走 220ms 过渡;换章/重排传 false。 */
const applyTextPageTransform = useCallback((nextPage: number, animate: boolean) => {
const applyTextPageTransform = useCallback(
(nextPage: number, animate: boolean) => {
const el = contentRef.current
if (!el) return
const stride = textPageStride()
el.style.transition = animate ? PAGE_TRANSITION : 'none'
// 正文多栏可能极宽,不要 translate3d / will-change,免得整章被提成巨大合成层。
el.style.transform = `translateX(-${nextPage * stride}px)`
appliedTextPageRef.current = nextPage
appliedTextVwRef.current = vwRef.current > 0 ? vwRef.current : (viewportRef.current?.clientWidth ?? 0)
},
[textPageStride],
)
const updateTextPageLabel = useCallback((nextPage: number) => {
const label = pageLabelRef.current
if (!label) return
const title = chapterTitleRef.current
label.textContent = title ? `${nextPage + 1} / ${pageCountRef.current} · ${title}` : `${nextPage + 1} / ${pageCountRef.current}`
}, [])
/**
* 重算「段落 → 页号」映射。正文是一条超长多栏流,段落落在哪一栏只能从布局读。
* 2500 段读一遍 offsetLeft 约 2ms(布局干净时),比让浏览器每帧自己重算便宜得多。
*/
const refreshParagraphColumns = useCallback(() => {
const el = contentRef.current
if (!el) return
const width = vwRef.current > 0 ? vwRef.current : (viewportRef.current?.clientWidth ?? 0)
const stride = Math.max(width, 1) + COLUMN_GAP
el.style.transition = animate ? PAGE_TRANSITION : 'none'
el.style.transform = `translateX(-${nextPage * stride}px)`
appliedTextPageRef.current = nextPage
appliedTextVwRef.current = width
const ps = el.querySelectorAll('p')
const stride = textPageStride()
const cols = new Int32Array(ps.length)
for (let i = 0; i < ps.length; i++) cols[i] = Math.floor(ps[i].offsetLeft / stride)
paragraphsRef.current = ps
paragraphColsRef.current = cols
// 映射变了,缓存的「谁在渲染」标记不能沿用:下次应用时以 DOM 现状为基准重算。
paragraphWarmRef.current = new Uint8Array(ps.length)
warmCenterRef.current = -1
}, [textPageStride])
/**
* 把当前页附近的段落固定成「真实渲染」,其余继续按需跳过。
* 只有分类真的变了的段落才写 DOM,所以窗口不动时这个函数是空转。
*/
const applyParagraphWarmWindow = useCallback(
(nextPage: number) => {
if (settings.pageMode !== 'page' || contentType !== 'text') return
const cols = paragraphColsRef.current
const ps = paragraphsRef.current
const warm = paragraphWarmRef.current
if (!cols || !ps || !warm || cols.length !== ps.length) return
const center = warmCenterRef.current
if (center >= 0 && Math.abs(nextPage - center) <= TEXT_WARM_SHIFT) return
if (center < 0) {
for (let i = 0; i < ps.length; i++) warm[i] = ps[i].style.contentVisibility === 'visible' ? 1 : 0
}
warmCenterRef.current = nextPage
const lo = nextPage - TEXT_WARM_PAGES
const hi = nextPage + TEXT_WARM_PAGES
let flipped = false
for (let i = 0; i < ps.length; i++) {
const want = cols[i] >= lo && cols[i] <= hi ? 1 : 0
if (warm[i] === want) continue
warm[i] = want
flipped = true
// 放进窗口 = visible(真实渲染),移出窗口 = auto(按需跳过,高度用记住的值)。
ps[i].style.setProperty('content-visibility', want ? 'visible' : 'auto')
}
// 可见性一变,段落高度就从估算值换成真实值,后面所有段落都会挪位,
// 缓存的映射随即过期。等下一帧布局落定再重算,别卡在这一帧里。
if (flipped) requestAnimationFrame(() => refreshParagraphColumns())
},
[settings.pageMode, contentType, refreshParagraphColumns],
)
/** 退出翻页模式时把整章段落放回按需跳过,别留着上一次的渲染窗口。 */
const resetParagraphWarmWindow = useCallback(() => {
const ps = paragraphsRef.current
const warm = paragraphWarmRef.current
if (!ps || !warm) return
for (let i = 0; i < ps.length; i++) {
if (!warm[i]) continue
warm[i] = 0
ps[i].style.setProperty('content-visibility', 'auto')
}
warmCenterRef.current = -1
}, [])
/**
* 把 pageRef 写回 React。长章每次 setPage 都会让整页重渲染卡住 1s+,
* 所以阅读翻页热路径绝不调用;只在打开菜单 / 换章 / 卸载时同步。
*/
const flushTextPageReactSync = useCallback(() => {
if (textPageSyncTimerRef.current !== null) {
clearTimeout(textPageSyncTimerRef.current)
textPageSyncTimerRef.current = null
}
setPage((p) => (p === pageRef.current ? p : pageRef.current))
}, [])
/**
* 文本翻到指定页(热路径零 setState)。
* - 正文多栏瞬移 + 页脚 DOM 更新
* - 点按/滚轮:无动画
* - 滑动松手:只动画跟手层
* dragDx 有值表示来自滑动松手。
*/
const commitTextPage = useCallback(
(nextPage: number, opts?: { dragDx?: number; dragBase?: number }) => {
const from = pageRef.current
const dragDx = opts?.dragDx
const fromSwipe = typeof dragDx === 'number'
const turn: 1 | -1 = nextPage >= from ? 1 : -1
pageRef.current = nextPage
applyTextPageTransform(nextPage, false)
updateTextPageLabel(nextPage)
applyParagraphWarmWindow(nextPage)
const dragEl = pageDragRef.current
if (fromSwipe && dragEl && nextPage !== from) {
settleTextDrag(dragEl, turn, dragDx, textPageStride(), opts?.dragBase ?? 0)
} else if (dragEl) {
// 点按翻页也可能打断上一次的归位动画。这里不能用 transition:'none' 直接抹掉残余:
// 那是硬跳。带上过渡滑回 0,起点就是当前的实际位置,画面才是连续的。
dragEl.style.transition = PAGE_TRANSITION
dragEl.style.transform = 'translate3d(0,0,0)'
}
},
[applyTextPageTransform, updateTextPageLabel, applyParagraphWarmWindow, textPageStride],
)
const relayout = useCallback(() => {
const vp = viewportRef.current
if (!vp) return
const width = vp.clientWidth
setVw(width)
const total = vp.scrollWidth
const el = contentRef.current
if (!vp || !el) return
// 列宽必须是整数、而且要和位移用的步长完全一致。
// clientWidth / state 是取整的,而实际布局宽度可能是小数(缩放、非整数 DPI 很常见,
// 比如 382.4)。容器宽 382.4、column-width 写 382 时,浏览器按容器宽排出 382.4 的列,
// 真实列距就变成 430.4,而位移按 430 算——每页差 0.4px,误差乘以页码:
// 第 339 页就偏了 135px,整页文字从句子中间切开。所以这里把容器宽钉成同一个整数,
// 真实列距就等于步长,页码再大也不会漂。
const width = Math.max(1, Math.round(vp.getBoundingClientRect().width))
const widthPx = `${width}px`
// 宽度和列宽都用命令式写:React 那边用的是 vw state,晚一帧才跟上,
// 这一帧里算出来的 scrollWidth / 页数就会是错的。
if (el.style.width !== widthPx) el.style.width = widthPx
if (el.style.columnWidth !== widthPx) el.style.columnWidth = widthPx
// 用正文列自己的 scrollWidth;视口在子元素 transform 时 scrollWidth 会抖,
// 导致翻一页就重新 setPageCount → 整页重渲染卡 1s+。
const total = el.scrollWidth ?? vp.scrollWidth
const count = Math.max(1, Math.ceil((total + COLUMN_GAP) / (width + COLUMN_GAP)))
setPageCount(count)
// 恢复进度 / 夹紧页码属于排版结果,不是用户翻页——关掉过渡,免得整章闪滑。
snapTextPageRef.current = true
setPage((p) => {
if (pendingEndRef.current && count > 0) {
pendingEndRef.current = false
return count - 1
}
if (pendingPosRef.current > 0) {
const pos = Math.min(pendingPosRef.current, count - 1)
pendingPosRef.current = 0
return pos
}
return Math.min(p, count - 1)
})
}, [])
const widthChanged = width !== vwRef.current
if (widthChanged) pageCountLockedRef.current = false
// content-visibility 只给视口附近的段落算真尺寸,远端的段落按估算高度参与分页,
// 所以 scrollWidth 会随阅读单向变大。锁定期内只接受变大(页数更准,且增长是单向
// 的,不会来回抖);不接受变小——正文一跳回估算值就会把页码夹回上一页,
// 表现出来就是「翻到下一页又被拉回上一页」。
const effectiveCount = pageCountLockedRef.current ? Math.max(count, pageCountRef.current) : count
const countGrew = effectiveCount !== pageCountRef.current
const pendingEnd = pendingEndRef.current && effectiveCount > 0
const pendingPos = pendingPosRef.current > 0
const clamped = Math.min(pageRef.current, Math.max(0, effectiveCount - 1))
const nextPage = pendingEnd
? effectiveCount - 1
: pendingPos
? Math.min(pendingPosRef.current, effectiveCount - 1)
: clamped
if (pendingEnd) pendingEndRef.current = false
if (pendingPos) pendingPosRef.current = 0
const pageChanged = nextPage !== pageRef.current
// 热路径上尺寸没变就别 setState——否则每次翻页都会重渲染 2000+ 段正文。
if (!widthChanged && !countGrew && !pageChanged && !pendingEnd && !pendingPos) {
if (count > 1) pageCountLockedRef.current = true
return
}
vwRef.current = width
pageCountRef.current = effectiveCount
pageRef.current = nextPage
applyParagraphWarmWindow(nextPage)
if (widthChanged) setVw(width)
if (countGrew) {
setPageCount(effectiveCount)
if (effectiveCount > 1) pageCountLockedRef.current = true
}
if (pageChanged || pendingEnd || pendingPos) {
snapTextPageRef.current = true
setPage(nextPage)
updateTextPageLabel(nextPage)
applyTextPageTransform(nextPage, false)
}
}, [applyTextPageTransform, updateTextPageLabel, applyParagraphWarmWindow])
useLayoutEffect(() => {
relayout()
}, [relayout, content, settings.fontSize, settings.lineHeight, settings.paragraphSpacing, settings.pageMode, vw])
// 正文/字号/列宽变了:段落落位全变了,重新算映射并重建渲染窗口。
if (settings.pageMode === 'page' && contentType === 'text') {
refreshParagraphColumns()
applyParagraphWarmWindow(pageRef.current)
} else {
resetParagraphWarmWindow()
}
}, [relayout, refreshParagraphColumns, applyParagraphWarmWindow, resetParagraphWarmWindow, content, settings.fontSize, settings.lineHeight, settings.paragraphSpacing, settings.pageMode, contentType, vw])
// 页码或列宽变了:若翻页回调里已经写过 transform 则跳过,避免把正在播的过渡重开一遍。
// 页脚页码的文字只由 DOM 直接写(翻页热路径不 setState),React 那边不渲染子节点。
// 两边都写的话,React 重渲染时会拿滞后的 page 去覆盖,覆盖失败时页面会闪一下旧页码。
// 这里在换章 / 页数变化时补写一次,保证页脚不为空。
useLayoutEffect(() => {
if (settings.pageMode !== 'page' || contentType !== 'text') return
updateTextPageLabel(pageRef.current)
}, [content, pageCount, chapterIndex, contentType, settings.pageMode, updateTextPageLabel])
// 列宽变化或换章恢复时对齐位移。
// 阅读热路径以 pageRef 为权威页码(不走 setPage);这里绝不能用滞后的 React page
// 反写 pageRef,否则会出现「翻到下一页又被拉回上一页」的来回抖动。
useLayoutEffect(() => {
if (contentType !== 'text' || settings.pageMode !== 'page') return
if (snapTextPageRef.current) {
snapTextPageRef.current = false
pageRef.current = page
applyTextPageTransform(page, false)
updateTextPageLabel(page)
return
}
// React page 还没跟上热路径:只在宽度变了时按 pageRef 重算位移。
if (page !== pageRef.current) {
if (appliedTextVwRef.current !== vw) {
applyTextPageTransform(pageRef.current, false)
updateTextPageLabel(pageRef.current)
}
return
}
if (appliedTextPageRef.current === page && appliedTextVwRef.current === vw) return
const animate = !snapTextPageRef.current && appliedTextPageRef.current !== page
snapTextPageRef.current = false
applyTextPageTransform(page, animate)
}, [page, vw, contentType, settings.pageMode, applyTextPageTransform])
applyTextPageTransform(page, false)
updateTextPageLabel(page)
}, [page, vw, contentType, settings.pageMode, applyTextPageTransform, updateTextPageLabel])
useEffect(() => {
const vp = viewportRef.current
if (!vp) return
const ro = new ResizeObserver(() => relayout())
// 合并同一帧里多次 resize,避免字体/滚动条抖动连带 relayout→setState。
let raf = 0
const ro = new ResizeObserver(() => {
if (raf) cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => {
raf = 0
relayout()
})
})
ro.observe(vp)
return () => ro.disconnect()
return () => {
if (raf) cancelAnimationFrame(raf)
ro.disconnect()
}
}, [relayout, content, settings.pageMode])
// 打开菜单时把 pageRef 写回 state,进度条才不会停在旧页。
useEffect(() => {
if (menuOpen) flushTextPageReactSync()
}, [menuOpen, flushTextPageReactSync])
// 滚动模式恢复进度
useEffect(() => {
if (settings.pageMode !== 'scroll' || content === null) return
@@ -558,13 +861,22 @@ export default function ReaderViewPage() {
[book, chapterIndex, chapters],
)
// 文本翻页进度:不依赖 React page(翻页热路径不 setPage),定时读 pageRef。
useEffect(() => {
// 只处理文本:听书/漫画的正文为空串(不是 null),不能落到页码保存逻辑里,
// 否则打开音频章 1.5 秒后就会把页码 0 写成进度,覆盖掉刚才恢复的秒数。
if (contentType !== 'text' || settings.pageMode !== 'page' || content === null || chapterIndex === null) return
const t = setTimeout(() => savePos(page), 1500)
return () => clearTimeout(t)
}, [page, content, chapterIndex, settings.pageMode, contentType, savePos])
let last = -1
const tick = () => {
const pos = pageRef.current
if (pos === last) return
last = pos
savePos(pos)
}
const t = setInterval(tick, 2000)
return () => {
clearInterval(t)
tick()
}
}, [content, chapterIndex, settings.pageMode, contentType, savePos])
// 漫画单页进度保存
useEffect(() => {
@@ -742,14 +1054,10 @@ export default function ReaderViewPage() {
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
return
}
// 文本翻页:先命令式挪正文列,再 setPage 更新页脚。顺序不能反——
// 否则长章重渲染会把过渡堵到卡顿结束才开始。
if (page > 0) {
const next = page - 1
applyTextPageTransform(next, true)
setPage(next)
} else goChapter(-1, true)
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, comicPage, page, goChapter, applyTextPageTransform])
// 文本翻页读 pageRef:动画期间 React page 可能还没跟上,连点不能被旧 state 卡住。
if (pageRef.current > 0) commitTextPage(pageRef.current - 1)
else goChapter(-1, true)
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, comicPage, goChapter, commitTextPage])
const goNext = useCallback(() => {
if (contentType === 'audio') {
@@ -773,12 +1081,9 @@ export default function ReaderViewPage() {
else el?.scrollBy({ top: window.innerHeight * 0.9, behavior: 'auto' })
return
}
if (page < pageCount - 1) {
const next = page + 1
applyTextPageTransform(next, true)
setPage(next)
} else goChapter(1)
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, media, comicPage, page, pageCount, goChapter, applyTextPageTransform])
if (pageRef.current < pageCountRef.current - 1) commitTextPage(pageRef.current + 1)
else goChapter(1)
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, media, comicPage, goChapter, commitTextPage])
// ── 点击分区(左 30% 上一页 / 中间呼出菜单 / 右 30% 下一页) ──
// 翻页模式用覆盖层上的三个按钮驱动;滚动模式不能再用覆盖层——
@@ -791,38 +1096,47 @@ export default function ReaderViewPage() {
// 「滑动后浏览器补发的 click」的去重都在 useHorizontalSwipe 里)。
//
// 跟手拖拽平移的是两个不同的元素:
// - 文本:正文列外面单独挂一层「跟手层」(pageDragRef)。分页平移由 applyTextPageTransform
// 写在里层(contentRef),跟手层只管手指位移;松手时两层同时动——跟手位移归零 +
// 页码变化——合起来就是一次连续滑动。分两层是为了避免跟手位移和 React/命令式页码
// 位移互相覆盖;页码不再经 React style 下发,长章翻页才不会先卡主线程再开动画。
// - 文本:正文列外面单独挂一层「跟手层」(pageDragRef)。正文多栏只瞬移,
// 滑页动画全在跟手层(settleTextDrag);长章不能给多栏做 transition。
// - 漫画:平移 ReaderComic 的舞台(comicStageRef)。舞台两侧摆着上一页/下一页
// (见那边的 carousel 分支),翻页时单元格的 left 偏移与舞台位移在同一次提交里
// 相抵,画面看不出切换。
const pageDragRef = useRef<HTMLDivElement>(null)
const comicStageRef = useRef<HTMLDivElement>(null)
const pageDragEnabled = settings.pageMode === 'page' && (contentType === 'text' || comicDragSinglePage)
/**
* 本次跟手手势开始时,跟手层/舞台已经带着的残余位移。
* 上一次翻页的归位动画没滑完就又滑了一次,新位移必须接着它算,否则画面跳一格。
*/
const dragBaseRef = useRef(0)
const onPageDragStart = useCallback(() => {
const el = contentType === 'image' ? comicStageRef.current : pageDragRef.current
dragBaseRef.current = el ? readTranslateX(el) : 0
}, [contentType])
const onPageDrag = useCallback(
(dx: number) => {
const el = contentType === 'image' ? comicStageRef.current : pageDragRef.current
if (!el) return
el.style.transition = 'none'
el.style.transform = `translateX(${dx}px)`
el.style.transform = `translate3d(${dragBaseRef.current + dx}px,0,0)`
},
[contentType],
)
const onPageDragEnd = useCallback(
(dx: number) => {
const base = dragBaseRef.current
dragBaseRef.current = 0
if (contentType === 'image') {
const el = comicStageRef.current
if (!el) return
if (dx <= -DRAG_TURN_DISTANCE) {
settleComicDrag(el, 1, dx, goNext)
settleComicDrag(el, 1, dx, base, goNext)
return
}
if (dx >= DRAG_TURN_DISTANCE) {
settleComicDrag(el, -1, dx, goPrev)
settleComicDrag(el, -1, dx, base, goPrev)
return
}
el.style.transition = PAGE_TRANSITION
@@ -831,19 +1145,38 @@ export default function ReaderViewPage() {
}
const el = pageDragRef.current
if (!el) return
// 跟手层归零与页码平移同时进行,合成一次连续滑动(位移不够时就是回弹)。
if (dx <= -DRAG_TURN_DISTANCE) {
if (pageRef.current < pageCountRef.current - 1) {
commitTextPage(pageRef.current + 1, { dragDx: dx, dragBase: base })
} else {
el.style.transition = PAGE_TRANSITION
el.style.transform = 'translate3d(0,0,0)'
goChapter(1)
}
return
}
if (dx >= DRAG_TURN_DISTANCE) {
if (pageRef.current > 0) {
commitTextPage(pageRef.current - 1, { dragDx: dx, dragBase: base })
} else {
el.style.transition = PAGE_TRANSITION
el.style.transform = 'translate3d(0,0,0)'
goChapter(-1, true)
}
return
}
// 位移不够:回弹,不翻页。
el.style.transition = PAGE_TRANSITION
el.style.transform = 'translateX(0px)'
if (dx <= -DRAG_TURN_DISTANCE) goNext()
else if (dx >= DRAG_TURN_DISTANCE) goPrev()
el.style.transform = 'translate3d(0,0,0)'
},
[contentType, goNext, goPrev],
[contentType, goNext, goPrev, commitTextPage, goChapter],
)
const { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, consumeSwipe } = useHorizontalSwipe({
onSwipeRight: goPrev,
onSwipeLeft: goNext,
minDistance: DRAG_TURN_DISTANCE,
onDragStart: pageDragEnabled ? onPageDragStart : undefined,
onDrag: pageDragEnabled ? onPageDrag : undefined,
onDragEnd: pageDragEnabled ? onPageDragEnd : undefined,
})
@@ -925,6 +1258,7 @@ export default function ReaderViewPage() {
}, [settings.pageMode, menuOpen, contentType, goPrev, goNext])
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
chapterTitleRef.current = currentChapter?.title ?? ''
// 顶栏高度量一次:菜单打开时正文下移,开头几行不被顶栏压住。
// 用 transform 而不是 padding,避免改变视口高度触发重新分页。
@@ -972,10 +1306,10 @@ export default function ReaderViewPage() {
),
onChange: (v: number) => {
if (settings.pageMode === 'page') {
const next = v - 1
applyTextPageTransform(next, true)
setPage(next)
} else {
commitTextPage(v - 1)
flushTextPageReactSync()
}
else {
const el = scrollRef.current
if (el) el.scrollTop = (v / 1000) * (el.scrollHeight - el.clientHeight)
}
@@ -1225,11 +1559,14 @@ export default function ReaderViewPage() {
)}
</div>
{/* 页脚页码(翻页模式) */}
{/* 页脚页码(翻页模式)。文字由 updateTextPageLabel 直接写 DOM,
React 不渲染子节点,避免重渲染时用滞后的页码覆盖。 */}
{settings.pageMode === 'page' && contentType === 'text' && content !== null && (
<div className="pointer-events-none pb-2 text-center text-2xs opacity-50" style={{ color: theme.text }}>
{page + 1} / {pageCount} · {currentChapter?.title ?? ''}
</div>
<div
ref={pageLabelRef}
className="pointer-events-none pb-2 text-center text-2xs opacity-50"
style={{ color: theme.text }}
/>
)}
{/* 主菜单(仿 legado ReadMenu) */}