feat(reader): P3+P4——音频源与漫画/图片源全链路支持

后端:
- /api/reader/media 签名媒体代理:HMAC-SHA256(jwtSecret, bookID|url) 防开放
  中转滥用;免 JWT(audio/img 元素带不了头)但需有效签名+书籍存在
- 携书源级请求头与 Referer(防盗链)、Range 透传(音频拖动进度 206)、
  m3u8 播放列表分片/密钥地址改写为签名代理(hls.js 无感续播)
- 音频/图片地址按最终页面 URL 绝对化,imageStyle 透传;
  GetContentForBook 将 tracks/images 统一改写为代理地址
前端:
- ReaderAudioPanel(仿 ReadAloudDialog):hls.js 播 m3u8 / <audio> 直链,
  上一章/播放暂停/下一章、进度条拖动、倍速 0.75–2x、按秒记忆进度、
  播完自动下一章、点背景呼出菜单
- ReaderComic:上下滚动 / 左右单页双模式(复用翻页/滚动切换)、
  图片懒加载+失败占位、滚动模式左右点击滚屏/中间呼菜单、
  单页模式点击区翻页、菜单进度条按图片序号、按图片序号记忆进度
- 菜单进度条按内容类型适配(文本页/音频章节/漫画图片序号)
This commit is contained in:
truewhile
2026-09-30 17:12:16 +08:00
parent 9cc21a47c5
commit fa41936095
9 changed files with 714 additions and 49 deletions
+2 -2
View File
@@ -120,8 +120,8 @@ web/src/
| P0 引擎地基 ✅ | 规则引擎核心(四分析器 + 规则拆分/组合/变量)+ AnalyzeUrl v1(GET/POST/charset/headers/变量/页码模式)+ 表结构 + 书源导入/管理 API + 搜索/详情/目录/正文/书架/进度/调试 API | 已完成:`internal/service/reader/rule/`(规则引擎,~2800 行,对齐 AnalyzeRule/AnalyzeByJSoup/AnalyzeByJSonPath/AnalyzeByXPath/AnalyzeByRegex/AnalyzeUrl/RuleAnalyzer)+ 服务层 + `/api/reader/*` 路由 + 单测/端到端测试全绿 |
| P1 文本源全链路 + 首页切换 | 搜索聚合(WS 进度)/详情/目录/正文(nextContentUrl 合并、缓存)+ 前端首页切换、书架、搜索、详情、文本阅读器 v1(阅读器样式仿 legado:9 宫格点击、主题、翻页动画) | 用纯规则型文本源完成「搜书→加入→阅读」全流程 |
| P2 JS 与兼容率爬坡 ✅ | goja 接入 + `java.*` 桥(网络/编解码/摘要/对称加密全家桶/规则回调,函数名对齐 JsExtensions)+ URL 规则 JS(analyzeJs/{{}}/js/bodyJs)+ cookie jar + 用户替换净化规则(含正则超时保护)+ 替换净化页 + 结构化冒烟链路(SmokeChain)+ `cmd/reader-smoke` 冒烟 CLI | JS 源可用;冒烟 CLI 跑公开书源集出各阶段通过率报告 |
| P3 音频源 | 播放列表解析、音频代理(带 UA/Referer)、音频播放器页(仿 ReadAloudDialog 布局:上一章/播放/下一章/定时/倍速)、进度记忆 | 音频源可听 |
| P4 漫画/图片源 | 图片列表解析(含翻页)、图片代理接入磁盘缓存、漫画阅读器双模式(MangaMenu:顶栏+底部胶囊)、预加载 | 漫画源可看 |
| P3 音频源 ✅ | 正文按音频类型返回播放列表(绝对化)+ `/api/reader/media` 签名媒体代理(HMAC 防滥用、Range 透传支持拖动、m3u8 分片/密钥地址重写)+ 阅读器音频面板(hls.js 播 m3u8、直链 `<audio>`、上一章/播放暂停/下一章、倍速 0.75–2x、进度按秒记忆、播完自动下一章) | 音频源可听 |
| P4 漫画/图片源 ✅ | 图片列表绝对化 + 经签名代理(带书源 Referer 防盗链头)+ 漫画阅读器(上下滚动/左右单页双模式、图片懒加载与加载失败占位、点击分区翻页/呼菜单、菜单进度条按图片序号、进度按图片序号记忆、下一章预取)+ imageStyle 透传 | 漫画源可看 |
| P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出;可选:本地 TXT/EPUB | 完整体验 |
P0–P2 是主体(约全部工作量 60–70%),P3/P4 相对独立可并行。
+2
View File
@@ -26,6 +26,8 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
// STRM 播放端点:strm 文件内容指向这里,Emby/Infuse 直接请求(无 JWT)。
api.GET("/strm/play/:provider/:file", strmPlayHandler(svc))
api.HEAD("/strm/play/:provider/:file", strmPlayHandler(svc))
// 阅读媒体代理:音频流/漫画图片,鉴权靠 HMAC 签名(audio/img 元素带不了 JWT)。
api.GET("/reader/media", readerMediaProxyHandler(svc))
// 115 中继/CloudDrive 授权回跳(authorization_id 会话 + 共享密钥校验)
api.POST("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
api.GET("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
+49
View File
@@ -2,8 +2,10 @@
package handler
import (
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -329,6 +331,53 @@ func readerDeleteReplaceRuleHandler(svc *service.Container) gin.HandlerFunc {
}
}
// readerMediaProxyHandler 音频流/漫画图片签名代理:
// 校验 HMAC 签名 → 携书源防盗链头拉取 → Range 透传(音频拖动)/ m3u8 重写。
func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
bookID := c.Query("b")
rawURL, err := svc.Reader.VerifyProxyURL(bookID, c.Query("u"), c.Query("s"))
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 http(s) 媒体地址"})
return
}
book, err := svc.Reader.GetBook(c.Request.Context(), bookID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "书籍不存在"})
return
}
resp, err := svc.Reader.FetchMedia(c.Request.Context(), book, rawURL, c.GetHeader("Range"))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()})
return
}
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
isPlaylist := strings.Contains(ct, "mpegurl") || strings.Contains(ct, "m3u8") ||
strings.HasSuffix(strings.ToLower(rawURL), ".m3u8")
if isPlaylist {
// m3u8:改写分片/密钥地址为签名代理后返回,hls.js 无感续播
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(data))
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
return
}
// 流式透传(含 206 Partial Content,支持音频拖动进度)
for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(h); v != "" {
c.Header(h, v)
}
}
c.Status(resp.StatusCode)
_, _ = io.Copy(c.Writer, resp.Body)
}
}
// readerBookContentHandler 书架维度正文(服务端应用替换净化规则)。
func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
+43
View File
@@ -0,0 +1,43 @@
package reader
import (
"net/url"
"testing"
"github.com/truewhile/MeBox/internal/config"
)
// TestProxyURLSignRoundTrip 验证媒体代理签名:往返还原 + 防篡改。
func TestProxyURLSignRoundTrip(t *testing.T) {
s := &ReaderService{cfg: &config.Config{Secrets: config.SecretsConfig{JWTSecret: "test-secret"}}}
const raw = "https://cdn.example.com/audio/ep1.mp3?token=abc"
signed := s.ProxyURL("book-1", raw)
parsed, err := url.Parse(signed)
if err != nil {
t.Fatal(err)
}
q := parsed.Query()
if q.Get("b") != "book-1" {
t.Fatalf("bookID = %q", q.Get("b"))
}
restored, err := s.VerifyProxyURL(q.Get("b"), q.Get("u"), q.Get("s"))
if err != nil {
t.Fatalf("verify failed: %v", err)
}
if restored != raw {
t.Fatalf("restored = %q", restored)
}
// 篡改签名必须被拒绝
if _, err := s.VerifyProxyURL(q.Get("b"), q.Get("u"), "00000000000000000000000000000000"); err == nil {
t.Fatal("expected signature rejection")
}
// 换书签必须被拒绝
if _, err := s.VerifyProxyURL("book-2", q.Get("u"), q.Get("s")); err == nil {
t.Fatal("expected cross-book signature rejection")
}
// 已是代理地址的不再二次包裹
if again := s.ProxyURL("book-1", signed); again != signed {
t.Fatalf("double wrap: %q", again)
}
}
+134 -8
View File
@@ -4,11 +4,15 @@ package reader
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"sync"
@@ -728,10 +732,109 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou
// ChapterContent 章节内容(按类型返回文本/音频/图片)。
type ChapterContent struct {
Type string `json:"type"` // text / audio / image
Content string `json:"content,omitempty"`
Tracks []string `json:"tracks,omitempty"` // 音频播放地址(m3u8/直链)
Images []string `json:"images,omitempty"` // 漫画图片列表
Type string `json:"type"` // text / audio / image
Content string `json:"content,omitempty"`
Tracks []string `json:"tracks,omitempty"` // 音频播放地址(已改写为服务端签名代理)
Images []string `json:"images,omitempty"` // 漫画图片列表(已改写为服务端签名代理)
ImageStyle string `json:"image_style,omitempty"` // 对应 legado ruleContent.imageStyle
}
// ─── 媒体代理(音频流 / 漫画图片,带防盗链头与 HMAC 签名) ──────────────────
// ProxyURL 将书源返回的媒体地址改写为签名代理地址。
// 签名 = HMAC-SHA256(jwtSecret, bookID|url),防止代理被滥用为开放中转。
func (s *ReaderService) ProxyURL(bookID, rawURL string) string {
if rawURL == "" || strings.HasPrefix(rawURL, "/api/") {
return rawURL
}
mac := hmac.New(sha256.New, []byte(s.cfg.Secrets.JWTSecret))
mac.Write([]byte(bookID + "|" + rawURL))
sig := hex.EncodeToString(mac.Sum(nil))[:32]
return "/api/reader/media?b=" + url.QueryEscape(bookID) +
"&u=" + base64.RawURLEncoding.EncodeToString([]byte(rawURL)) + "&s=" + sig
}
// VerifyProxyURL 校验签名并还原媒体地址。
func (s *ReaderService) VerifyProxyURL(bookID, encoded, sig string) (string, error) {
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", fmt.Errorf("媒体地址解码失败")
}
mac := hmac.New(sha256.New, []byte(s.cfg.Secrets.JWTSecret))
mac.Write([]byte(bookID + "|" + string(raw)))
expect := hex.EncodeToString(mac.Sum(nil))[:32]
if !hmac.Equal([]byte(expect), []byte(sig)) {
return "", fmt.Errorf("媒体地址签名校验失败")
}
return string(raw), nil
}
// FetchMedia 服务端拉取媒体资源(携带书源级请求头与 Referer,支持 Range 透传)。
// 调用方负责关闭 resp.Body。
func (s *ReaderService) FetchMedia(ctx context.Context, book *model.ReaderBook, rawURL, rangeHeader string) (*http.Response, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
for k, v := range helper.HTTPHeaderPresets() {
httpReq.Header.Set(k, v)
}
// 书源级请求头
if s.repo != nil {
if found, findErr := s.repo.GetSourceByURL(ctx, book.Origin); findErr == nil && found != nil && found.Header != "" {
var headers map[string]any
if json.Unmarshal([]byte(found.Header), &headers) == nil {
for k, v := range headers {
httpReq.Header.Set(k, fmt.Sprintf("%v", v))
}
}
}
}
if httpReq.Header.Get("Referer") == "" && book.Origin != "" {
httpReq.Header.Set("Referer", strings.TrimSuffix(book.Origin, "/")+"/")
}
if rangeHeader != "" {
httpReq.Header.Set("Range", rangeHeader)
}
return s.http.Do(httpReq)
}
// RewritePlaylist 重写 m3u8 播放列表:分片与密钥地址改写为签名代理地址。
func (s *ReaderService) RewritePlaylist(bookID, playlistURL, text string) string {
base, err := url.Parse(playlistURL)
if err != nil {
return text
}
lines := strings.Split(text, "\n")
for i, line := range lines {
t := strings.TrimSpace(line)
if t == "" {
continue
}
if strings.HasPrefix(t, "#") {
// EXT-X-KEY / EXT-X-MAP 的 URI="..." 属性
if idx := strings.Index(t, `URI="`); idx >= 0 {
rest := t[idx+len(`URI="`):]
if end := strings.Index(rest, `"`); end >= 0 {
inner := rest[:end]
abs := GetAbsoluteURLOf(base, inner)
lines[i] = t[:idx] + `URI="` + s.ProxyURL(bookID, abs) + `"` + rest[end+1:]
}
}
continue
}
lines[i] = s.ProxyURL(bookID, GetAbsoluteURLOf(base, t))
}
return strings.Join(lines, "\n")
}
// GetAbsoluteURLOf 以解析后的 base URL 拼绝对地址(内部工具)。
func GetAbsoluteURLOf(base *url.URL, ref string) string {
parsed, err := url.Parse(strings.TrimSpace(ref))
if err != nil {
return ref
}
return base.ResolveReference(parsed).String()
}
// GetContent 抓取正文(含 nextContentUrl 翻页合并与净化替换)。
@@ -750,6 +853,7 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
}
var parts []string
url := chapterURL
lastFinalURL := ""
for i := 0; i < maxContentNextPage; i++ {
runner := s.jsRunnerFor(ctx, src, bs, "", 0)
req, err := rule.ParseAnalyzeUrlWithJS(url, "", 0, src.SourceURL, runner)
@@ -764,6 +868,7 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
if err != nil {
return nil, err
}
lastFinalURL = finalURL
ar := s.newRuleAnalyzer(ctx, src, bs, "", 0, body, finalURL)
list, err := ar.GetStringList(SPtr(cr.Content), nil, false)
@@ -786,12 +891,21 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
}
out := &ChapterContent{Content: content}
switch src.Type {
case 1:
case 1: // 音频:逐行地址,按最终页面 URL 绝对化
out.Type = "audio"
out.Tracks = splitURLLines(content)
case 2:
for _, line := range splitURLLines(content) {
if abs := rule.GetAbsoluteURL(lastFinalURL, line); abs != "" {
out.Tracks = append(out.Tracks, abs)
}
}
case 2: // 漫画/图片
out.Type = "image"
out.Images = splitURLLines(content)
for _, line := range splitURLLines(content) {
if abs := rule.GetAbsoluteURL(lastFinalURL, line); abs != "" {
out.Images = append(out.Images, abs)
}
}
out.ImageStyle = SPtr(cr.ImageStyle)
default:
out.Type = "text"
}
@@ -832,6 +946,11 @@ func (s *ReaderService) AddBook(ctx context.Context, userID string, origin Searc
return book, nil
}
// GetBook 按 ID 取书(媒体代理等使用)。
func (s *ReaderService) GetBook(ctx context.Context, id string) (*model.ReaderBook, error) {
return s.repo.GetBook(ctx, id)
}
// ListBooks 书架列表。
func (s *ReaderService) ListBooks(ctx context.Context, userID string) ([]model.ReaderBook, error) {
return s.repo.ListBooks(ctx, userID)
@@ -996,6 +1115,13 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
if out.Type == "text" {
out.Content = s.applyUserReplaceRules(ctx, userID, book.Name, out.Content)
}
// 音频/图片地址改写为签名代理(浏览器播放/展示无法带防盗链头)
for i, t := range out.Tracks {
out.Tracks[i] = s.ProxyURL(book.ID, t)
}
for i, img := range out.Images {
out.Images[i] = s.ProxyURL(book.ID, img)
}
return out, nil
}
+1
View File
@@ -97,6 +97,7 @@ export interface ReaderChapterContent {
content?: string
tracks?: string[]
images?: string[]
image_style?: string
}
export interface ReaderReplaceRule {
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight, Loader2, Pause, Play } from 'lucide-react'
// 音频播放面板(仿 legado ReadAloudDialog transport 行):
// hls.js 播 m3u8,<audio> 播直链;上一章/播放暂停/下一章 + 进度条 + 倍速。
interface ReaderAudioPanelProps {
src: string
title: string
theme: { bg: string; text: string; accent: string }
initialPos: number // 恢复进度(秒)
onProgress: (seconds: number) => void
onPrevChapter: () => void
onNextChapter: () => void
onEnded: () => void
onToggleMenu: () => void
}
const RATES = [0.75, 1, 1.25, 1.5, 2]
function fmt(sec: number): string {
if (!Number.isFinite(sec)) return '0:00'
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${String(s).padStart(2, '0')}`
}
export function ReaderAudioPanel({
src,
title,
theme,
initialPos,
onProgress,
onPrevChapter,
onNextChapter,
onEnded,
onToggleMenu,
}: ReaderAudioPanelProps) {
const audioRef = useRef<HTMLAudioElement>(null)
const hlsRef = useRef<{ destroy: () => void } | null>(null)
const restoredRef = useRef(false)
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)
// 换源/换章:重建播放器
useEffect(() => {
const audio = audioRef.current
if (!audio) return
restoredRef.current = false
setLoading(true)
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 = RATES[rateIdx]
}, [rateIdx])
const toggle = () => {
const audio = audioRef.current
if (!audio) return
if (audio.paused) void audio.play().catch(() => undefined)
else audio.pause()
}
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="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 ? '暂停' : '播放'}
>
{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>
<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 }}
>
{RATES[rateIdx]}x
</button>
<audio
ref={audioRef}
className="hidden"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onLoadedMetadata={(e) => {
setDur(e.currentTarget.duration)
setLoading(false)
if (!restoredRef.current && initialPos > 0 && initialPos < e.currentTarget.duration) {
e.currentTarget.currentTime = initialPos
}
restoredRef.current = true
void e.currentTarget.play().catch(() => undefined)
}}
onTimeUpdate={(e) => {
setCur(e.currentTarget.currentTime)
onProgress(e.currentTarget.currentTime)
}}
onEnded={onEnded}
/>
</div>
)
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
// 漫画/图片阅读器(仿 legado MangaMenu 能力面):
// 上下滚动(默认)/ 左右单页两种模式;点击分区翻页/呼出菜单;图片懒加载。
interface ReaderComicProps {
images: string[]
theme: { bg: string; text: string; accent: string }
mode: 'page' | 'scroll'
page: number // 单页模式当前页(0 基)
onZone: (zone: 'left' | 'center' | 'right') => void
initialImage: number
onProgress: (imageIndex: number) => void
scrollTo: number | null // 滚动模式:外部要求滚动到的图片序号
onScrolled: () => void
}
function ComicImage({ src, theme }: { src: string; theme: { bg: string; text: string; accent: string } }) {
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
return (
<div className="relative w-full">
{state === 'loading' && (
<div className="flex h-64 w-full items-center justify-center" style={{ color: theme.text }}>
<Loader2 className="animate-spin opacity-50" size={22} />
</div>
)}
{state === 'error' ? (
<div className="flex h-40 w-full items-center justify-center text-xs opacity-50" style={{ color: theme.text }}>
图片加载失败
</div>
) : (
<img
src={src}
loading="lazy"
alt=""
onLoad={() => setState('ok')}
onError={() => setState('error')}
className={`block w-full ${state === 'loading' ? 'hidden' : ''}`}
style={{ backgroundColor: theme.bg }}
/>
)}
</div>
)
}
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled }: ReaderComicProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
const restoredRef = useRef(false)
// 滚动模式:恢复进度(图片序号)并上报当前图
useEffect(() => {
if (mode !== 'scroll') return
if (!restoredRef.current && initialImage > 0 && imgRefs.current[initialImage]) {
imgRefs.current[initialImage]?.scrollIntoView({ block: 'start' })
}
restoredRef.current = true
}, [mode, initialImage, images.length])
// 外部要求滚动到指定图片(菜单进度条)
useEffect(() => {
if (mode !== 'scroll' || scrollTo === null) return
imgRefs.current[scrollTo]?.scrollIntoView({ block: 'start' })
onScrolled()
}, [scrollTo, mode, onScrolled])
useEffect(() => {
if (mode !== 'page') return
restoredRef.current = false
}, [mode])
if (images.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm opacity-60" style={{ color: theme.text }}>
本章没有图片
</div>
)
}
if (mode === 'page') {
// 单页模式:翻页由外层点击区驱动(page 语义 = 图片序号)
const idx = Math.min(Math.max(page, 0), images.length - 1)
return (
<div className="flex h-full items-center justify-center">
<ComicImage key={images[idx]} src={images[idx]} theme={theme} />
</div>
)
}
// 上下滚动模式
return (
<div
ref={scrollRef}
className="h-full overflow-y-auto"
onScroll={(e) => {
const el = e.currentTarget
// 以视口顶部所在图片为当前进度
let current = 0
for (let i = 0; i < imgRefs.current.length; i++) {
const node = imgRefs.current[i]
if (node && node.offsetTop <= el.scrollTop + el.clientHeight * 0.4) current = i
}
onProgress(current)
if (!restoredRef.current && initialImage >= 0) {
restoredRef.current = true
}
}}
>
{images.map((src, i) => (
<div
key={i}
ref={(node) => {
imgRefs.current[i] = node
}}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const x = (e.clientX - rect.left) / rect.width
if (x < 0.3) {
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
} else if (x > 0.7) {
scrollRef.current?.scrollBy({ top: window.innerHeight * 0.9, behavior: 'auto' })
} else {
onZone('center')
}
}}
>
<ComicImage src={src} theme={theme} />
<p className="pb-1 text-center text-2xs opacity-40" style={{ color: theme.text }}>
{i + 1} / {images.length}
</p>
</div>
))}
<div className="h-10" />
</div>
)
}
+162 -39
View File
@@ -4,7 +4,6 @@ import toast from 'react-hot-toast'
import {
ArrowLeft,
BookOpen,
ChevronLeft,
LayoutList,
ListEnd,
Loader2,
@@ -18,6 +17,8 @@ import { Virtuoso } from 'react-virtuoso'
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent } from '../../api/reader'
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { ReaderAudioPanel } from './ReaderAudioPanel'
import { ReaderComic } from './ReaderComic'
// 文本阅读器(仿 legado ReadBookActivity:主题配色、点击区域、上下章、
// 进度记忆、翻页/滚动双模式;桌面端限宽居中,支持键盘翻页)。
@@ -52,6 +53,13 @@ export default function ReaderViewPage() {
const [page, setPage] = useState(0)
const [pageCount, setPageCount] = useState(1)
const [vw, setVw] = useState(0)
// 音频/漫画媒体状态
const [media, setMedia] = useState<ReaderChapterContent | null>(null)
const [restorePos, setRestorePos] = useState(0) // 音频秒数 / 漫画图片序号
const [comicPage, setComicPage] = useState(0)
const [currentImage, setCurrentImage] = useState(0)
const [scrollToImage, setScrollToImage] = useState<number | null>(null)
const lastMediaSaveRef = useRef(0)
const contentCache = useRef(new Map<string, ReaderChapterContent>())
const pendingPosRef = useRef(0)
const pendingEndRef = useRef(false)
@@ -120,9 +128,16 @@ export default function ReaderViewPage() {
}
if (cancelled) return
setContentType(ct.type)
setMedia(ct)
setContent(ct.content ?? '')
setPage(0)
// pendingPosRef 保留:排版完成后由 relayout / 滚动恢复效果消费
setComicPage(0)
setCurrentImage(0)
// 音频/漫画的进度恢复值在这里取走(文本由排版/滚动效果消费 pendingPosRef)
if (ct.type !== 'text') {
setRestorePos(pendingPosRef.current)
pendingPosRef.current = 0
}
// 进度上报(pos 保留原值,排版完成后才被消费清零)
readerAPI
.saveProgress(book.id, { chapter_index: chapterIndex, pos: pendingPosRef.current, chapter_title: ch.title })
@@ -211,6 +226,39 @@ export default function ReaderViewPage() {
return () => clearTimeout(t)
}, [page, content, chapterIndex, settings.pageMode, savePos])
// 漫画单页进度保存
useEffect(() => {
if (contentType !== 'image' || settings.pageMode !== 'page' || !media || chapterIndex === null) return
const t = setTimeout(() => savePos(comicPage), 1200)
return () => clearTimeout(t)
}, [comicPage, contentType, settings.pageMode, media, chapterIndex, savePos])
// 音频/漫画滚动:节流进度保存
const throttledMediaSave = useCallback(
(pos: number) => {
if (!book || chapterIndex === null) return
const now = Date.now()
if (now - lastMediaSaveRef.current < (contentType === 'audio' ? 10_000 : 2_000)) return
lastMediaSaveRef.current = now
savePos(pos)
},
[book, chapterIndex, contentType, savePos],
)
// 漫画:翻到章尾/恢复进度定位
useEffect(() => {
if (!media || media.type !== 'image') return
const n = media.images?.length ?? 0
if (pendingEndRef.current && n > 0) {
setComicPage(n - 1)
pendingEndRef.current = false
return
}
if (restorePos > 0) {
setComicPage(Math.min(restorePos, Math.max(0, n - 1)))
}
}, [media, restorePos])
// ── 章节导航 ──
const goChapter = useCallback(
(delta: number, atEnd = false) => {
@@ -229,15 +277,34 @@ export default function ReaderViewPage() {
)
const goPrev = useCallback(() => {
if (contentType === 'audio') {
goChapter(-1)
return
}
if (contentType === 'image' && settings.pageMode === 'page') {
if (comicPage > 0) setComicPage((p) => p - 1)
else goChapter(-1, true)
return
}
if (settings.pageMode === 'scroll') {
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
return
}
if (page > 0) setPage((p) => p - 1)
else goChapter(-1, true)
}, [settings.pageMode, page, goChapter])
}, [contentType, settings.pageMode, comicPage, page, goChapter])
const goNext = useCallback(() => {
if (contentType === 'audio') {
goChapter(1)
return
}
if (contentType === 'image' && settings.pageMode === 'page') {
const n = media?.images?.length ?? 0
if (comicPage < n - 1) setComicPage((p) => p + 1)
else goChapter(1)
return
}
if (settings.pageMode === 'scroll') {
const el = scrollRef.current
if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 2) goChapter(1)
@@ -246,7 +313,7 @@ export default function ReaderViewPage() {
}
if (page < pageCount - 1) setPage((p) => p + 1)
else goChapter(1)
}, [settings.pageMode, page, pageCount, goChapter])
}, [contentType, settings.pageMode, media, comicPage, page, pageCount, goChapter])
// ── 键盘(桌面端) ──
useEffect(() => {
@@ -268,6 +335,45 @@ export default function ReaderViewPage() {
}, [goPrev, goNext, menuOpen, panel])
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
// 菜单进度条按内容类型适配:文本=页/滚动位置,音频=章节,漫画=图片序号
const imageCount = media?.images?.length ?? 0
const sliderCfg = (() => {
if (contentType === 'audio') {
return {
min: 0,
max: Math.max(0, chapters.length - 1),
value: Math.max(0, chapterIndex ?? 0),
onChange: (v: number) => setChapterIndex(v),
}
}
if (contentType === 'image') {
const max = Math.max(0, imageCount - 1)
if (settings.pageMode === 'page') {
return { min: 0, max, value: Math.min(comicPage, max), onChange: (v: number) => setComicPage(v) }
}
return { min: 0, max, value: Math.min(currentImage, max), onChange: (v: number) => setScrollToImage(v) }
}
return {
min: 1,
max: Math.max(1, settings.pageMode === 'page' ? pageCount : 1000),
value:
settings.pageMode === 'page'
? page + 1
: Math.round(
((scrollRef.current?.scrollTop ?? 0) /
Math.max(1, (scrollRef.current?.scrollHeight ?? 1) - (scrollRef.current?.clientHeight ?? 1))) *
1000,
),
onChange: (v: number) => {
if (settings.pageMode === 'page') setPage(v - 1)
else {
const el = scrollRef.current
if (el) el.scrollTop = (v / 1000) * (el.scrollHeight - el.clientHeight)
}
},
}
})()
const paragraphs = (content ?? '').split('\n').map((p) => p.trim()).filter(Boolean)
// ── 渲染 ──
@@ -287,7 +393,42 @@ export default function ReaderViewPage() {
{/* 正文视口 */}
<div className="relative flex-1 overflow-hidden">
<div className="mx-auto h-full w-full max-w-[900px]">
{settings.pageMode === 'page' ? (
{contentType === 'audio' ? (
media && media.tracks && media.tracks.length > 0 ? (
<ReaderAudioPanel
src={media.tracks[0]}
title={currentChapter?.title ?? book?.name ?? '播放'}
theme={theme}
initialPos={restorePos}
onProgress={throttledMediaSave}
onPrevChapter={() => goChapter(-1)}
onNextChapter={() => goChapter(1)}
onEnded={() => goChapter(1)}
onToggleMenu={() => setMenuOpen((v) => !v)}
/>
) : (
<div className="flex h-full items-center justify-center text-sm opacity-60" style={{ color: theme.text }}>
{loadingStage !== null ? <Loader2 className="animate-spin opacity-60" size={24} /> : '本章没有可播放的音频'}
</div>
)
) : contentType === 'image' ? (
<ReaderComic
images={media?.images ?? []}
theme={theme}
mode={settings.pageMode}
page={comicPage}
onZone={(zone) => {
if (zone === 'center') setMenuOpen((v) => !v)
}}
initialImage={restorePos}
onProgress={(idx) => {
setCurrentImage(idx)
throttledMediaSave(idx)
}}
scrollTo={scrollToImage}
onScrolled={() => setScrollToImage(null)}
/>
) : settings.pageMode === 'page' ? (
<div ref={viewportRef} className="relative h-full overflow-hidden">
<div
ref={contentRef}
@@ -368,17 +509,18 @@ export default function ReaderViewPage() {
)}
</div>
{/* 点击区域(9 宫格简化为三列,语义同 legado 默认配置:左右翻页、中间呼出菜单) */}
<div className="absolute inset-0 grid grid-cols-[30%_40%_30%]">
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
<button
type="button"
aria-label="菜单"
onClick={() => setMenuOpen((v) => !v)}
className="cursor-default"
/>
<button type="button" aria-label="下一页" onClick={goNext} className="cursor-e-resize" />
</div>
{(contentType === 'text' || (contentType === 'image' && settings.pageMode === 'page')) && (
<div className="absolute inset-0 grid grid-cols-[30%_40%_30%]">
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
<button
type="button"
aria-label="菜单"
onClick={() => setMenuOpen((v) => !v)}
className="cursor-default"
/>
<button type="button" aria-label="下一页" onClick={goNext} className="cursor-e-resize" />
</div>
)}
</div>
{/* 页脚页码(翻页模式) */}
@@ -433,20 +575,10 @@ export default function ReaderViewPage() {
</button>
<input
type="range"
min={1}
max={Math.max(1, settings.pageMode === 'page' ? pageCount : 1000)}
value={settings.pageMode === 'page' ? page + 1 : Math.round(
((scrollRef.current?.scrollTop ?? 0) /
Math.max(1, (scrollRef.current?.scrollHeight ?? 1) - (scrollRef.current?.clientHeight ?? 1))) *
1000,
)}
onChange={(e) => {
if (settings.pageMode === 'page') setPage(Number(e.target.value) - 1)
else {
const el = scrollRef.current
if (el) el.scrollTop = (Number(e.target.value) / 1000) * (el.scrollHeight - el.clientHeight)
}
}}
min={sliderCfg.min}
max={sliderCfg.max}
value={sliderCfg.value}
onChange={(e) => sliderCfg.onChange(Number(e.target.value))}
className="flex-1 accent-current"
style={{ accentColor: theme.accent }}
/>
@@ -587,15 +719,6 @@ export default function ReaderViewPage() {
</>
)}
{/* 音频/漫画提示(P3/P4 开放) */}
{contentType !== 'text' && content !== null && (
<div className="absolute inset-0 flex items-center justify-center" style={{ color: theme.text }}>
<div className="text-center">
<ChevronLeft className="mx-auto opacity-30" size={28} />
<p className="mt-2 text-sm opacity-70">{contentType === 'audio' ? '音频播放将在后续版本开放' : '漫画阅读将在后续版本开放'}</p>
</div>
</div>
)}
</div>
)
}