mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce3baee8f1 | |||
| 2494f0c0cd | |||
| c4e9926352 | |||
| f3b88fad7c |
@@ -94,7 +94,12 @@
|
||||
- 正文留边:左右 16px、上下 8px(对齐 legado 默认左右16/上下6),分页列宽按留边后的视口宽计算,正文不贴屏幕边。
|
||||
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
|
||||
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
|
||||
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;滚动模式保留浏览器原生滚动不接管,菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
|
||||
- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
|
||||
- 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。
|
||||
- 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`<img>` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。
|
||||
- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
|
||||
- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 `<img>`,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`<div>`/`</div>`)与空行当作无内容忽略,绝不吞掉可读文字。
|
||||
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
|
||||
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
|
||||
|
||||
**本地书籍(对应 legado 本地 TXT / EPUB)**
|
||||
|
||||
@@ -333,13 +333,15 @@ func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
// SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。
|
||||
SourceIDs []string `json:"source_ids"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key)
|
||||
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -501,6 +501,11 @@ func (s *ReaderService) LocalChapterContent(
|
||||
// EPUB 里的图片换成签名地址(前端按 [img] 标记渲染)
|
||||
text = s.rewriteLocalImages(book.ID, text)
|
||||
text = s.applyUserReplaceRules(ctx, userID, book.Name, text)
|
||||
// 图片版 EPUB(整章都是 [img] 标记)同样是漫画:按图片类型下发,
|
||||
// 前端走漫画阅读器,才有整页缩放与双页铺开。
|
||||
if imgs, ok := imageMarkersOnly(text); ok {
|
||||
return &ChapterContent{Type: "image", Images: imgs, declaredType: -1}, nil
|
||||
}
|
||||
return &ChapterContent{Type: "text", Content: text, declaredType: -1}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
|
||||
if books, _, err := svc.Search(ctx, "会员"); err != nil {
|
||||
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("未登录时不应搜到结果: %+v", books)
|
||||
@@ -188,7 +188,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
}
|
||||
|
||||
// ── 登录后搜索:应携带 Cookie 并成功 ──
|
||||
books, skipped, err := svc.Search(ctx, "会员")
|
||||
books, skipped, err := svc.Search(ctx, "会员", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -210,7 +210,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
if info.LoggedIn || len(info.Cookies) != 0 {
|
||||
t.Fatalf("登出后不应残留登录态: %+v", info)
|
||||
}
|
||||
if books, _, err := svc.Search(ctx, "会员"); err != nil {
|
||||
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("登出后不应还能搜到结果: %+v", books)
|
||||
@@ -367,7 +367,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
||||
}`
|
||||
sourceID := prepareLoginSource(t, svc, srcJSON)
|
||||
|
||||
if _, _, err := svc.Search(ctx, "任意"); err != nil {
|
||||
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 自动捕获被关闭:不应出现 auto=from-response
|
||||
@@ -389,7 +389,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
||||
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.Search(ctx, "任意"); err != nil {
|
||||
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
|
||||
|
||||
@@ -737,17 +737,17 @@ type searchHit struct {
|
||||
}
|
||||
|
||||
// Search 多源聚合搜索(同步返回,P1 改为 WS 流式推送)。
|
||||
func (s *ReaderService) Search(ctx context.Context, key string) ([]SearchBook, []SearchSkipped, error) {
|
||||
//
|
||||
// sourceIDs 是本次的搜索范围(对应 legado SearchScope.getBookSourceParts):
|
||||
// 空表示「全部书源」——所有已启用书源;非空则只搜其中仍存在、仍启用的书源。
|
||||
// 范围内一个可搜书源都不剩时退回全部启用(对应 legado 范围失效时的兜底),
|
||||
// 这样删源/停源后不会因为残留的旧选择把搜索变成「什么都搜不到」。
|
||||
func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string) ([]SearchBook, []SearchSkipped, error) {
|
||||
sources, err := s.repo.ListSources(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var enabled []model.ReaderBookSource
|
||||
for _, src := range sources {
|
||||
if src.Enabled {
|
||||
enabled = append(enabled, src)
|
||||
}
|
||||
}
|
||||
enabled := enabledSourcesForScope(sources, sourceIDs)
|
||||
if len(enabled) == 0 {
|
||||
return nil, nil, fmt.Errorf("没有已启用的书源")
|
||||
}
|
||||
@@ -783,6 +783,34 @@ func (s *ReaderService) Search(ctx context.Context, key string) ([]SearchBook, [
|
||||
return mergeSearchResults(hits, key), skipped, nil
|
||||
}
|
||||
|
||||
// enabledSourcesForScope 按搜索范围挑出可搜的书源(保持 ListSources 的 customOrder)。
|
||||
// 空范围 = 全部启用;范围里的书源不存在或已停用时忽略;全被忽略则退回全部启用。
|
||||
func enabledSourcesForScope(sources []model.ReaderBookSource, sourceIDs []string) []model.ReaderBookSource {
|
||||
enabled := make([]model.ReaderBookSource, 0, len(sources))
|
||||
for _, src := range sources {
|
||||
if src.Enabled {
|
||||
enabled = append(enabled, src)
|
||||
}
|
||||
}
|
||||
if len(sourceIDs) == 0 {
|
||||
return enabled
|
||||
}
|
||||
want := make(map[string]struct{}, len(sourceIDs))
|
||||
for _, id := range sourceIDs {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
scoped := make([]model.ReaderBookSource, 0, len(enabled))
|
||||
for _, src := range enabled {
|
||||
if _, ok := want[src.ID]; ok {
|
||||
scoped = append(scoped, src)
|
||||
}
|
||||
}
|
||||
if len(scoped) == 0 {
|
||||
return enabled
|
||||
}
|
||||
return scoped
|
||||
}
|
||||
|
||||
// SearchSkipped 搜索失败的书源与原因(书源管理调试用)。
|
||||
type SearchSkipped struct {
|
||||
SourceID string `json:"source_id"`
|
||||
@@ -1696,6 +1724,63 @@ func rewriteContentImageMarkers(content, baseURL string, proxy func(string) stri
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// imageMarkersOnly 判断一章正文是不是「整章都是图片」:非空行全部是 [img] 标记行。
|
||||
//
|
||||
// 文本型漫画源(拷贝漫画等,bookSourceType=0 但正文规则给 <img>,见
|
||||
// rewriteContentImageMarkers)和图片版 EPUB 的正文就是这样一连串的标记行。
|
||||
// 这类章节本质上就是漫画,必须按漫画渲染:文本阅读器的分页把每张图当成一列,
|
||||
// 一屏只看得到一张,桌面端也没法两页并排——正是「漫画没法双页铺开」的根因。
|
||||
// 所以这里识别出来之后由调用方把类型改成 image,交给漫画阅读器。
|
||||
//
|
||||
// 只认「非空行全是标记」:混了正文的章节一律保持 text,绝不能把文字吃掉。
|
||||
// 返回的地址已经是签名代理地址(调用点在此之前刚做过改写)。
|
||||
func imageMarkersOnly(content string) ([]string, bool) {
|
||||
var images []string
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
// 空行和一个孤立的 HTML 标签(<div>/</div> 之类,正文规则给 outerHTML 时很常见)
|
||||
// 都没有可读文字,不影响判断。
|
||||
if trimmed == "" || isHTMLTagOnly(trimmed) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, imgMarkerPrefix) {
|
||||
return nil, false
|
||||
}
|
||||
addr := strings.TrimSpace(strings.TrimPrefix(trimmed, imgMarkerPrefix))
|
||||
if addr == "" {
|
||||
return nil, false
|
||||
}
|
||||
images = append(images, addr)
|
||||
}
|
||||
return images, len(images) > 0
|
||||
}
|
||||
|
||||
// isHTMLTagOnly 判断整行是不是一个孤立的 HTML 标签(<div>、</div>、<br> 之类)。
|
||||
// 即「<」开头、「>」结尾,且尖括号内是标签名的形状(字母开头,后面可带属性)。
|
||||
// 标签名两侧的空白(源码里常见的 < div > 这类手写残渣)一并忽略。
|
||||
func isHTMLTagOnly(line string) bool {
|
||||
if len(line) < 3 || line[0] != '<' || line[len(line)-1] != '>' {
|
||||
return false
|
||||
}
|
||||
inner := strings.TrimSpace(strings.TrimPrefix(line[1:len(line)-1], "/"))
|
||||
if inner == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range inner {
|
||||
switch {
|
||||
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z'):
|
||||
continue
|
||||
case i == 0:
|
||||
// 首字符不是字母:<3、<!-- 注释 --> 之类,一律不当标签
|
||||
return false
|
||||
default:
|
||||
// 标签名之后(属性、空白、自闭合斜杠)都算标签
|
||||
return true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// imageRefsInLine 取出一行正文里的图片地址。
|
||||
//
|
||||
// - 正文规则给的就是地址 → 原样返回;
|
||||
@@ -2190,6 +2275,15 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
|
||||
return s.ProxyURL(book.ID, u)
|
||||
})
|
||||
}
|
||||
// 整章都是 [img] 标记 → 这就是一章漫画,改成图片类型交给漫画阅读器。
|
||||
// 若还按文本下发,前端会把它当文字按列分页,一屏只有一张图(双页铺开也无从谈起)。
|
||||
if out.Type == "text" {
|
||||
if imgs, ok := imageMarkersOnly(out.Content); ok {
|
||||
out.Type = "image"
|
||||
out.Images = imgs
|
||||
out.Content = ""
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,76 @@ func TestRewriteContentImageMarkersWithoutProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageMarkersOnly 整章都是 [img] 标记时判定为图片章(前端才能走漫画阅读器、
|
||||
// 双页铺开);只要掺了能读的正文就必须保持文本,不能把文字吃掉。
|
||||
func TestImageMarkersOnly(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
wantOK bool
|
||||
wantURLs []string
|
||||
}{
|
||||
{
|
||||
name: "整章都是标记",
|
||||
content: imgMarkerPrefix + "/a.jpg\n" + imgMarkerPrefix + "/b.jpg\n",
|
||||
wantOK: true,
|
||||
wantURLs: []string{"/a.jpg", "/b.jpg"},
|
||||
},
|
||||
{
|
||||
name: "夹着空行 / 孤立 html 标签 / 首尾空白",
|
||||
content: "\n" + imgMarkerPrefix + "/a.jpg\n \n<div>\n</div>\n< br >\n" +
|
||||
" " + imgMarkerPrefix + " /b.jpg \n",
|
||||
wantOK: true,
|
||||
wantURLs: []string{"/a.jpg", "/b.jpg"},
|
||||
},
|
||||
{
|
||||
name: "掺了正文就保持文本(图 + 长段落)",
|
||||
content: imgMarkerPrefix + "/a.jpg\n" + "第一句话。\n" + imgMarkerPrefix + "/b.jpg\n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "纯文本",
|
||||
content: "第一章\n正文正文\n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "空正文",
|
||||
content: "\n\n \n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "标记地址为空",
|
||||
content: imgMarkerPrefix + "/a.jpg\n" + imgMarkerPrefix + " \n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
// 「<3」这类不是标签,是有内容的一行,不能当成空白忽略
|
||||
name: "非标签的尖括号行",
|
||||
content: imgMarkerPrefix + "/a.jpg\n3 < 5\n",
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := imageMarkersOnly(c.content)
|
||||
if ok != c.wantOK {
|
||||
t.Errorf("%s: ok = %v,期望 %v(urls=%v)", c.name, ok, c.wantOK, got)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(got) != len(c.wantURLs) {
|
||||
t.Errorf("%s: urls = %v,期望 %v", c.name, got, c.wantURLs)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.wantURLs[i] {
|
||||
t.Errorf("%s: urls[%d] = %q,期望 %q", c.name, i, got[i], c.wantURLs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchCheckKeyWord 校验关键字的取值规则(对应 legado getCheckKeyword):
|
||||
// 含 http/::/++/-- 的值是地址或扩展标记,不当作关键字。
|
||||
func TestSearchCheckKeyWord(t *testing.T) {
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestSearchSendsQueryString(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
||||
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅")
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestSearchResponseUsesEmptyArrays(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
||||
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅")
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestSearchPreservesExistingPercentEncoding(t *testing.T) {
|
||||
out, _ := json.Marshal(src)
|
||||
_ = prepareLoginSource(t, svc, string(out))
|
||||
|
||||
if _, _, err := svc.Search(t.Context(), "宠魅"); err != nil {
|
||||
if _, _, err := svc.Search(t.Context(), "宠魅", nil); err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
if gotQuery != "宠魅" {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:搜索范围的过滤逻辑(对应 legado SearchScope.getBookSourceParts)。
|
||||
// 覆盖「只搜指定源」「空范围=全部」「范围失效退回全部」三种情形。
|
||||
|
||||
func scopeEchoSourceJSON(t *testing.T, name, serverURL string) string {
|
||||
t.Helper()
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": serverURL,
|
||||
"bookSourceName": name,
|
||||
"searchUrl": serverURL + "/search?q={{key}}",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "$.data",
|
||||
"name": "$.book_name",
|
||||
"author": "$.author",
|
||||
"bookUrl": "$.book_url",
|
||||
},
|
||||
}
|
||||
out, _ := json.Marshal(src)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func scopeEchoServer(t *testing.T, bookName string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
payload := map[string]any{
|
||||
"data": []map[string]string{{
|
||||
"book_name": bookName,
|
||||
"author": "作者",
|
||||
"book_url": "/book/1",
|
||||
}},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestSearchRespectsSourceScope(t *testing.T) {
|
||||
srvA := scopeEchoServer(t, "甲书")
|
||||
defer srvA.Close()
|
||||
srvB := scopeEchoServer(t, "乙书")
|
||||
defer srvB.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
for _, raw := range []string{
|
||||
scopeEchoSourceJSON(t, "源A", srvA.URL),
|
||||
scopeEchoSourceJSON(t, "源B", srvB.URL),
|
||||
} {
|
||||
if _, err := svc.ImportSources(t.Context(), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sources, err := svc.ListSources(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idByName := map[string]string{}
|
||||
for _, s := range sources {
|
||||
idByName[s.Name] = s.ID
|
||||
}
|
||||
|
||||
names := func(books []SearchBook) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, b := range books {
|
||||
out[b.Name] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 指定单个源:只应搜到该源的结果
|
||||
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
t.Fatalf("源A 不应失败: %+v", skipped)
|
||||
}
|
||||
if got := names(books); len(got) != 1 || !got["甲书"] {
|
||||
t.Fatalf("只搜源A 应只返回甲书,实际 %+v", books)
|
||||
}
|
||||
|
||||
// 空范围:默认全部启用书源,两个源都应命中
|
||||
books, _, err = svc.Search(t.Context(), "书", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := names(books); len(got) != 2 || !got["甲书"] || !got["乙书"] {
|
||||
t.Fatalf("空范围应搜到两个源的结果,实际 %+v", books)
|
||||
}
|
||||
|
||||
// 范围里的源已不存在(删源/换设备)时退回全部启用,而不是搜不到
|
||||
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := names(books); len(got) != 2 {
|
||||
t.Fatalf("失效范围应退回全部启用,实际 %+v", books)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { appRoutes, type AppRoute } from './appRoutes'
|
||||
import { Layout } from './components/Layout'
|
||||
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
|
||||
import { ReaderMiniPlayer } from './pages/reader/ReaderMiniPlayer'
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })))
|
||||
const ReaderRoutes = lazy(() => import('./pages/reader/ReaderRoutes').then((m) => ({ default: m.default })))
|
||||
|
||||
@@ -93,6 +94,8 @@ export default function App() {
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
{/* 听书迷你条:播放引擎常驻,离开阅读页后仍可从这里控制 */}
|
||||
<ReaderMiniPlayer />
|
||||
</Suspense>
|
||||
</AppErrorBoundary>
|
||||
)
|
||||
|
||||
@@ -245,11 +245,13 @@ export const readerAPI = {
|
||||
setSourceVariable: (id: string, variable: string) => api.put(`/reader/sources/${id}/variable`, { variable }),
|
||||
|
||||
// ── 搜索 / 详情 / 目录 / 正文 ──
|
||||
search: (key: string) =>
|
||||
// 多源聚合搜索。sourceIds 为空表示搜索范围=全部启用书源(默认);
|
||||
// 非空则只搜这些书源(后端会忽略其中已删除/停用的,全部失效时退回全部启用)。
|
||||
search: (key: string, sourceIds: string[] = []) =>
|
||||
api
|
||||
.post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>(
|
||||
'/reader/search',
|
||||
{ key },
|
||||
{ key, source_ids: sourceIds },
|
||||
longOpts,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
buildSpreadIndexOf,
|
||||
buildSpreads,
|
||||
hasImageSize,
|
||||
loadImageSize,
|
||||
type ComicSpread,
|
||||
} from '../utils/comicSpread'
|
||||
|
||||
// 漫画双页铺开的分组 + 尺寸探测。
|
||||
//
|
||||
// 分组结果依赖每张图是否「横跨两页」,而这要等图片尺寸探测出来才能判断,
|
||||
// 于是探测每完成一张就 bump 一次版本号、重新分组。为避免把整章图片都拉一遍,
|
||||
// 只探测当前页附近的一小段窗口:向前看 8 张,翻到下一屏时它的尺寸已经就绪,
|
||||
// 不会出现「先按竖版配好、渲染完再重排」的跳动。
|
||||
|
||||
/** 当前页往后探测几张。 */
|
||||
const PROBE_AHEAD = 8
|
||||
/** 当前页往前回看几张(滑块往回拖时补上断档)。 */
|
||||
const PROBE_BEHIND = 1
|
||||
|
||||
export interface ComicSpreads {
|
||||
/** 每屏要显示的图片序号。 */
|
||||
spreads: ComicSpread[]
|
||||
/** 图片序号 → 所在屏序号。 */
|
||||
spreadIndexOf: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param srcs 整章图片地址。
|
||||
* @param enabled 只在真的会用到双页时探测,单页/滚动模式不额外发请求。
|
||||
* @param center 当前图片序号,决定探测窗口。
|
||||
*/
|
||||
export function useComicSpreads(srcs: string[], enabled: boolean, center: number): ComicSpreads {
|
||||
const [revision, setRevision] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || srcs.length === 0) return
|
||||
let cancelled = false
|
||||
const from = Math.max(0, center - PROBE_BEHIND)
|
||||
const to = Math.min(srcs.length - 1, center + PROBE_AHEAD)
|
||||
const targets: string[] = []
|
||||
for (let i = from; i <= to; i += 1) {
|
||||
const src = srcs[i]
|
||||
if (src && !hasImageSize(src)) targets.push(src)
|
||||
}
|
||||
if (targets.length === 0) return
|
||||
for (const src of targets) {
|
||||
loadImageSize(src)
|
||||
.then(() => {
|
||||
if (!cancelled) setRevision((v) => v + 1)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [srcs, enabled, center])
|
||||
|
||||
const spreads = useMemo(() => {
|
||||
// revision 参与依赖:尺寸探测每完成一张就重新分组,宽图才能正确独占一屏
|
||||
void revision
|
||||
return buildSpreads(srcs)
|
||||
}, [srcs, revision])
|
||||
|
||||
const spreadIndexOf = useMemo(() => buildSpreadIndexOf(spreads, srcs.length), [spreads, srcs.length])
|
||||
|
||||
return { spreads, spreadIndexOf }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* 滚轮平滑滚动:把离散的滚轮格数累加成一个「目标位置」,
|
||||
* 再让 scrollTop 每帧向它逼近,于是滚动像手机上下滑动一样连续、带减速惯性,
|
||||
* 而不是浏览器的整格跳变。
|
||||
*
|
||||
* 参考帧间隔(60Hz)下的逼近比例:越大越跟手,越小越飘。
|
||||
*/
|
||||
const EASE_PER_FRAME = 0.2
|
||||
/** 每帧的基准毫秒数。按真实帧间隔换算,120Hz 屏上速度才不会翻倍。 */
|
||||
const FRAME_MS = 1000 / 60
|
||||
/** 与目标相差小于这个像素直接落位,省掉最后一帧帧逼近。 */
|
||||
const EPSILON = 0.5
|
||||
/** 单帧最大推进间隔,避免切标签页回来后一步跳很远。 */
|
||||
const MAX_FRAME_MS = 64
|
||||
|
||||
/**
|
||||
* 给一个纵向滚动容器接上「类手机滑动」的滚轮滚动。
|
||||
*
|
||||
* - 只用滚轮事件驱动,触摸/拖动滚动条时立刻交还控制权,不和手指打架。
|
||||
* - `ctrl/cmd + 滚轮`(浏览器缩放)不接管。
|
||||
* - 容器不可滚动时不做 `preventDefault`。
|
||||
*
|
||||
* @param ref 滚动容器(`overflow-y: auto`)。
|
||||
* @param enabled 是否接管;翻页模式等由别处处理滚轮的场景传 `false`。
|
||||
*/
|
||||
export function useSmoothWheelScroll(ref: RefObject<HTMLElement>, enabled: boolean): void {
|
||||
const targetRef = useRef<number | null>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const lastFrameRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el || !enabled) return
|
||||
|
||||
const stop = () => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
}
|
||||
|
||||
const step = (now: number) => {
|
||||
rafRef.current = null
|
||||
const target = targetRef.current
|
||||
const node = ref.current
|
||||
if (target === null || !node) return
|
||||
|
||||
const dt = lastFrameRef.current === 0 ? FRAME_MS : Math.min(MAX_FRAME_MS, now - lastFrameRef.current)
|
||||
lastFrameRef.current = now
|
||||
|
||||
const diff = target - node.scrollTop
|
||||
if (Math.abs(diff) < EPSILON) {
|
||||
node.scrollTop = target
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
const before = node.scrollTop
|
||||
node.scrollTop = before + diff * (1 - Math.pow(1 - EASE_PER_FRAME, dt / FRAME_MS))
|
||||
// 到了滚动边界(或内容变短被浏览器夹住)时 scrollTop 不动了,
|
||||
// 再继续逼近就是 rAF 空转,直接收手。
|
||||
if (node.scrollTop === before) {
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
return
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// ctrl/cmd+滚轮是浏览器缩放,别抢
|
||||
if (e.ctrlKey || e.metaKey || e.deltaY === 0) return
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
if (max <= 0) return
|
||||
e.preventDefault()
|
||||
// deltaMode: 0=像素 1=行 2=页,统一折算成像素
|
||||
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1
|
||||
const base = targetRef.current ?? el.scrollTop
|
||||
targetRef.current = Math.min(max, Math.max(0, base + e.deltaY * unit))
|
||||
if (rafRef.current === null) {
|
||||
lastFrameRef.current = 0
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
|
||||
// 手指按下(移动端滑动、拖滚动条、点分区)立刻停掉动画,把控制权交还用户
|
||||
const onTakeover = () => stop()
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
el.addEventListener('pointerdown', onTakeover)
|
||||
el.addEventListener('touchstart', onTakeover, { passive: true })
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('pointerdown', onTakeover)
|
||||
el.removeEventListener('touchstart', onTakeover)
|
||||
stop()
|
||||
}
|
||||
}, [ref, enabled])
|
||||
}
|
||||
@@ -1,28 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Gauge,
|
||||
ListMusic,
|
||||
Loader2,
|
||||
Pause,
|
||||
Play,
|
||||
Scissors,
|
||||
Timer,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Gauge, ListMusic, Loader2, Pause, Play, Scissors, Timer, X } from 'lucide-react'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
|
||||
import type { ReaderChapter } from '../../api/reader'
|
||||
import {
|
||||
AUDIO_CREDITS_MAX,
|
||||
AUDIO_SPEEDS,
|
||||
AUDIO_TIMERS,
|
||||
useReaderSettingsStore,
|
||||
} from '../../stores/readerSettings'
|
||||
import { useReaderAudioStore } from '../../stores/readerAudio'
|
||||
import { AUDIO_CREDITS_MAX, AUDIO_SPEEDS, AUDIO_TIMERS } from '../../stores/readerSettings'
|
||||
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
|
||||
import { readerCoverSrc } from '../../utils/readerCover'
|
||||
import { getReaderAudioEngine } from './readerAudioEngine'
|
||||
|
||||
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
|
||||
// hls.js 播 m3u8,<audio> 播直链。
|
||||
@@ -30,35 +14,12 @@ import { readerCoverSrc } from '../../utils/readerCover'
|
||||
// 动作行:章节选择、定时关闭、倍速、跳过片头片尾。
|
||||
// 片头片尾按 legado 语义:全新开播(进度 0)时 seek 到 openCredits,播放到
|
||||
// duration - closeCredits 即等同播完自动下一章;两者都以秒计、0 表示不跳过。
|
||||
//
|
||||
// 这个组件只是一层「视图」:真正的 <audio> 与全部播放状态都在常驻的
|
||||
// readerAudioEngine 单例里,面板挂载/卸载都不影响播放(见该文件顶部说明)。
|
||||
|
||||
interface ReaderAudioPanelProps {
|
||||
src: string
|
||||
title: string
|
||||
/** 书籍封面:做背景(强模糊)与居中圆形封面,对应 legado ivBg / ivCover。 */
|
||||
cover: string
|
||||
theme: { bg: string; text: string; accent: string }
|
||||
initialPos: number // 恢复进度(秒)
|
||||
/** 片头跳过秒数(Book.openCredits)。 */
|
||||
openCredits: number
|
||||
/** 片尾跳过秒数(Book.closeCredits)。 */
|
||||
closeCredits: number
|
||||
chapters: ReaderChapter[]
|
||||
chapterIndex: number | null
|
||||
/** 是否还有下一章(决定片尾跳过/播完是续播还是停住)。 */
|
||||
hasNext: boolean
|
||||
/** 该音轨由服务端转码,首次播放需要等转码完成。 */
|
||||
transcoding?: boolean
|
||||
onProgress: (seconds: number) => void
|
||||
/**
|
||||
* 立即落一次进度(不节流)。只在「用户要离开这一章」时用(切后台/关页面),
|
||||
* 把节流窗口里最后几秒补上;正常播放走 onProgress 的节流上报。
|
||||
*/
|
||||
onCommitProgress: (seconds: number) => void
|
||||
onPrevChapter: () => void
|
||||
onNextChapter: () => void
|
||||
onSelectChapter: (index: number) => void
|
||||
onEnded: () => void
|
||||
onCreditsChange: (open: number, close: number) => void
|
||||
onToggleMenu: () => void
|
||||
}
|
||||
|
||||
@@ -84,56 +45,34 @@ function fmtCountdown(sec: number): string {
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function ReaderAudioPanel({
|
||||
src,
|
||||
title,
|
||||
cover,
|
||||
theme,
|
||||
initialPos,
|
||||
openCredits,
|
||||
closeCredits,
|
||||
chapters,
|
||||
chapterIndex,
|
||||
hasNext,
|
||||
transcoding = false,
|
||||
onProgress,
|
||||
onCommitProgress,
|
||||
onPrevChapter,
|
||||
onNextChapter,
|
||||
onSelectChapter,
|
||||
onEnded,
|
||||
onCreditsChange,
|
||||
onToggleMenu,
|
||||
}: ReaderAudioPanelProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const hlsRef = useRef<{ destroy: () => void } | null>(null)
|
||||
const restoredRef = useRef(false)
|
||||
const skippedEndRef = useRef(false)
|
||||
/** 章节抽屉的虚拟列表句柄:分组下拉靠它整段跳转。 */
|
||||
export function ReaderAudioPanel({ theme, onToggleMenu }: ReaderAudioPanelProps) {
|
||||
const engine = useMemo(() => getReaderAudioEngine(), [])
|
||||
|
||||
const title = useReaderAudioStore((s) => s.chapterTitle)
|
||||
const cover = useReaderAudioStore((s) => s.cover)
|
||||
const chapters = useReaderAudioStore((s) => s.chapters)
|
||||
const chapterIndex = useReaderAudioStore((s) => s.chapterIndex)
|
||||
const status = useReaderAudioStore((s) => s.status)
|
||||
const audioError = useReaderAudioStore((s) => s.error)
|
||||
const cur = useReaderAudioStore((s) => s.cur)
|
||||
const dur = useReaderAudioStore((s) => s.dur)
|
||||
const speed = useReaderAudioStore((s) => s.speed)
|
||||
const transcoding = useReaderAudioStore((s) => s.transcoding)
|
||||
const openCredits = useReaderAudioStore((s) => s.openCredits)
|
||||
const closeCredits = useReaderAudioStore((s) => s.closeCredits)
|
||||
const timerActive = useReaderAudioStore((s) => s.timerActive)
|
||||
const timerLeft = useReaderAudioStore((s) => s.timerLeft)
|
||||
const timerPreset = useReaderAudioStore((s) => s.timerPreset)
|
||||
|
||||
const playing = status === 'playing'
|
||||
const loading = status === 'loading'
|
||||
|
||||
const tocRef = useRef<VirtuosoHandle>(null)
|
||||
|
||||
const speed = useReaderSettingsStore((s) => s.audioSpeed)
|
||||
const setSpeed = useReaderSettingsStore((s) => s.setAudioSpeed)
|
||||
const defaultTimer = useReaderSettingsStore((s) => s.audioTimerMinutes)
|
||||
const setDefaultTimer = useReaderSettingsStore((s) => s.setAudioTimerMinutes)
|
||||
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [cur, setCur] = useState(0)
|
||||
const [dur, setDur] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
// 音轨加载失败(格式不支持 / 转码失败):界面上要给出原因,不能一直停在「加载中」
|
||||
const [audioError, setAudioError] = useState('')
|
||||
const [sheet, setSheet] = useState<Sheet>('none')
|
||||
/** 章节抽屉顶部「区间下拉」当前选中的组号,随列表滚动同步。 */
|
||||
const [groupIndex, setGroupIndex] = useState(0)
|
||||
// 当前音轨的最后播放位置(秒)。timeupdate 只在播放中触发,离开页面时要用它补报一次。
|
||||
const lastTimeRef = useRef(0)
|
||||
// onCommitProgress 每次渲染都可能换实例(父级闭包里的 chapterIndex 会变),
|
||||
// 卸载时只能通过 ref 拿到最新的那个,否则会写错章节。
|
||||
const commitRef = useRef(onCommitProgress)
|
||||
useEffect(() => {
|
||||
commitRef.current = onCommitProgress
|
||||
}, [onCommitProgress])
|
||||
|
||||
// 封面可能被防盗链挡掉:加载失败就退回主题色占位,不留破图
|
||||
const [coverOK, setCoverOK] = useState(true)
|
||||
useEffect(() => {
|
||||
@@ -142,168 +81,22 @@ export function ReaderAudioPanel({
|
||||
// 明文 http 封面在 https 部署下会被混合内容策略拦掉,统一经后端代理下发
|
||||
const coverSrc = readerCoverSrc(cover)
|
||||
|
||||
// 定时关闭:timerLeft 为剩余秒数,0 表示未开启(legado BaseReadAloudService.timeMinute)
|
||||
const [timerLeft, setTimerLeft] = useState(0)
|
||||
const [timerActive, setTimerActive] = useState(false)
|
||||
const [timerPreset, setTimerPreset] = useState(defaultTimer)
|
||||
const timerDeadlineRef = useRef(0)
|
||||
|
||||
// 片头片尾草稿值(滑杆拖动中先本地预览,松手才落库)
|
||||
const [creditDraft, setCreditDraft] = useState({ open: openCredits, close: closeCredits })
|
||||
useEffect(() => {
|
||||
setCreditDraft({ open: openCredits, close: closeCredits })
|
||||
}, [openCredits, closeCredits])
|
||||
|
||||
// ── 换源/换章:重建播放器 ──
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
restoredRef.current = false
|
||||
skippedEndRef.current = false
|
||||
setLoading(true)
|
||||
setAudioError('')
|
||||
setCur(0)
|
||||
setDur(0)
|
||||
// 换章时清零:否则离开页面时会把上一章的位置补报成新章节的进度
|
||||
lastTimeRef.current = 0
|
||||
let cancelled = false
|
||||
let hls: { destroy: () => void } | null = null
|
||||
|
||||
const setup = async () => {
|
||||
if (src.includes('.m3u8')) {
|
||||
const mod = await import('hls.js')
|
||||
const Hls = mod.default
|
||||
if (cancelled) return
|
||||
if (Hls.isSupported()) {
|
||||
const inst = new Hls({ enableWorker: true })
|
||||
inst.loadSource(src)
|
||||
inst.attachMedia(audio)
|
||||
inst.on(Hls.Events.ERROR, (_e, data) => {
|
||||
if (data.fatal) setLoading(false)
|
||||
})
|
||||
hls = inst
|
||||
hlsRef.current = inst
|
||||
} else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
audio.src = src // Safari 原生 HLS
|
||||
}
|
||||
} else {
|
||||
audio.src = src
|
||||
}
|
||||
}
|
||||
void setup()
|
||||
return () => {
|
||||
cancelled = true
|
||||
hls?.destroy()
|
||||
hlsRef.current = null
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
}
|
||||
}, [src])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (audio) audio.playbackRate = speed
|
||||
}, [speed])
|
||||
|
||||
// ── 离开这一章时补报进度 ──
|
||||
// 进度平时靠 timeupdate 节流上报(听书 5s 一次),而 timeupdate 只在播放中触发:
|
||||
// 用户按退出/切后台那一瞬间的位置没人上报,节流窗口内最后几秒就丢了。
|
||||
// 这里只处理「离开」事件:换章不会卸载本面板(src 变化时 lastTimeRef 已清零),
|
||||
// 因此不会把上一章的位置写到新章节头上。
|
||||
useEffect(() => {
|
||||
const flush = () => {
|
||||
if (lastTimeRef.current > 0) commitRef.current(lastTimeRef.current)
|
||||
}
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') flush()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
window.addEventListener('pagehide', flush)
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.removeEventListener('pagehide', flush)
|
||||
flush()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── 定时关闭 ──
|
||||
const applyTimer = useCallback(
|
||||
(minutes: number) => {
|
||||
setDefaultTimer(minutes)
|
||||
setTimerPreset(minutes)
|
||||
if (minutes <= 0) {
|
||||
timerDeadlineRef.current = 0
|
||||
setTimerLeft(0)
|
||||
setTimerActive(false)
|
||||
return
|
||||
}
|
||||
timerDeadlineRef.current = Date.now() + minutes * 60_000
|
||||
setTimerLeft(minutes * 60)
|
||||
setTimerActive(true)
|
||||
},
|
||||
[setDefaultTimer],
|
||||
)
|
||||
|
||||
// 进场套用上次的定时设置(legado 起朗读服务时 setTimer(AppConfig.ttsTimer))
|
||||
const timerBootRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (timerBootRef.current) return
|
||||
timerBootRef.current = true
|
||||
if (defaultTimer > 0) applyTimer(defaultTimer)
|
||||
}, [applyTimer, defaultTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (!timerActive) return
|
||||
const id = window.setInterval(() => {
|
||||
const audio = audioRef.current
|
||||
// 暂停期间不倒计时(legado doDs 只在播放中扣分钟),把截止时间顺延
|
||||
if (audio && audio.paused) {
|
||||
timerDeadlineRef.current += 1000
|
||||
return
|
||||
}
|
||||
const left = Math.max(0, Math.round((timerDeadlineRef.current - Date.now()) / 1000))
|
||||
setTimerLeft(left)
|
||||
if (left <= 0) {
|
||||
timerDeadlineRef.current = 0
|
||||
setTimerActive(false)
|
||||
audio?.pause()
|
||||
toast('定时结束,已暂停播放')
|
||||
}
|
||||
}, 1000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [timerActive])
|
||||
|
||||
// ── 播放控制 ──
|
||||
const toggle = () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
if (audio.paused) void audio.play().catch(() => undefined)
|
||||
else audio.pause()
|
||||
}
|
||||
|
||||
const seekBy = useCallback((delta: number) => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
const total = Number.isFinite(audio.duration) ? audio.duration : audio.currentTime + Math.abs(delta)
|
||||
const next = Math.min(Math.max(0, audio.currentTime + delta), Math.max(0, total - 0.2))
|
||||
audio.currentTime = next
|
||||
setCur(next)
|
||||
}, [])
|
||||
|
||||
const seekTo = useCallback((seconds: number) => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
audio.currentTime = seconds
|
||||
setCur(seconds)
|
||||
}, [])
|
||||
|
||||
const changeSpeed = (v: number) => {
|
||||
setSpeed(v)
|
||||
}
|
||||
const toggle = () => engine.toggle()
|
||||
const seekBy = useCallback((delta: number) => engine.seekBy(delta), [engine])
|
||||
const seekTo = useCallback((seconds: number) => engine.seekTo(seconds), [engine])
|
||||
const changeSpeed = (v: number) => engine.setSpeed(v)
|
||||
const applyTimer = (minutes: number) => engine.applyTimer(minutes)
|
||||
|
||||
const saveCredits = (open: number, close: number) => {
|
||||
setCreditDraft({ open, close })
|
||||
onCreditsChange(open, close)
|
||||
engine.setCredits(open, close)
|
||||
}
|
||||
|
||||
const sheetStyle = { backgroundColor: theme.bg, color: theme.text, borderColor: theme.text + '22' }
|
||||
@@ -326,14 +119,11 @@ export function ReaderAudioPanel({
|
||||
}
|
||||
// 每次打开章节抽屉都把区间下拉对齐到当前章节:上次可能停在第 3 组,
|
||||
// 续读已到第 12 组,沿用旧值会显示错误的区间。
|
||||
if (next === 'chapters') setGroupIndex(chapterGroupIndexOf(chapterIndex ?? 0, chapters.length))
|
||||
if (next === 'chapters') setGroupIndex(chapterGroupIndexOf(chapterIndex, chapters.length))
|
||||
setSheet(next)
|
||||
}
|
||||
|
||||
const chapterList = useMemo(
|
||||
() => chapters.map((c, i) => ({ ...c, i })),
|
||||
[chapters],
|
||||
)
|
||||
const chapterList = useMemo(() => chapters.map((c, i) => ({ ...c, i })), [chapters])
|
||||
|
||||
// 上千章的目录整段跳转:每 100 条一组,下拉里选区间即可(见 utils/chapterGroups.ts)。
|
||||
// 目录不足一组(≤100 章)时不显示下拉——那时一屏能扫完,多了反而是噪声。
|
||||
@@ -446,7 +236,7 @@ export function ReaderAudioPanel({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrevChapter}
|
||||
onClick={() => engine.prev()}
|
||||
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
|
||||
aria-label="上一章"
|
||||
title="上一章"
|
||||
@@ -492,7 +282,7 @@ export function ReaderAudioPanel({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNextChapter}
|
||||
onClick={() => engine.next()}
|
||||
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
|
||||
aria-label="下一章"
|
||||
title="下一章"
|
||||
@@ -602,7 +392,7 @@ export function ReaderAudioPanel({
|
||||
<Virtuoso
|
||||
ref={tocRef}
|
||||
data={chapterList}
|
||||
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
||||
initialTopMostItemIndex={Math.max(0, chapterIndex)}
|
||||
rangeChanged={(range) =>
|
||||
setGroupIndex(chapterGroupIndexOf(range.startIndex, chapterList.length))
|
||||
}
|
||||
@@ -614,7 +404,7 @@ export function ReaderAudioPanel({
|
||||
onClick={() => {
|
||||
if (row.is_volume) return
|
||||
closeSheet()
|
||||
onSelectChapter(row.i)
|
||||
engine.selectChapter(row.i)
|
||||
}}
|
||||
className={`block w-full truncate px-4 py-2.5 text-left text-xs ${
|
||||
row.is_volume ? 'font-bold opacity-70' : ''
|
||||
@@ -764,71 +554,6 @@ export function ReaderAudioPanel({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
className="hidden"
|
||||
preload="metadata"
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onError={(e) => {
|
||||
// 解码不了时浏览器只给错误码;这里把「能看懂的原因」摆到界面上。
|
||||
// 服务端返回的正文说明(例如未装 ffmpeg)拿不到,只能按错误码给通用解释。
|
||||
const code = e.currentTarget.error?.code
|
||||
setLoading(false)
|
||||
setAudioError(
|
||||
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||
? '浏览器无法播放该音频格式,且服务端转码不可用(请检查 ffmpeg 是否已安装)'
|
||||
: code === MediaError.MEDIA_ERR_NETWORK
|
||||
? '音频加载失败,请检查网络或源文件是否还在'
|
||||
: '音频无法播放',
|
||||
)
|
||||
}}
|
||||
onLoadedMetadata={(e) => {
|
||||
const audio = e.currentTarget
|
||||
setDur(audio.duration)
|
||||
setLoading(false)
|
||||
audio.playbackRate = speed
|
||||
if (!restoredRef.current) {
|
||||
const total = Number.isFinite(audio.duration) ? audio.duration : 0
|
||||
if (initialPos > 0 && (total === 0 || initialPos < total)) {
|
||||
// 有进度:按存档续播(legado: position != 0 时不套用片头)
|
||||
audio.currentTime = initialPos
|
||||
setCur(initialPos)
|
||||
} else if (openCredits > 0 && (total === 0 || total > openCredits + 1)) {
|
||||
// 全新开播:跳到片头结束位置(legado: skipStartMs)
|
||||
audio.currentTime = openCredits
|
||||
setCur(openCredits)
|
||||
}
|
||||
}
|
||||
restoredRef.current = true
|
||||
void audio.play().catch(() => undefined)
|
||||
}}
|
||||
onTimeUpdate={(e) => {
|
||||
const audio = e.currentTarget
|
||||
const t = audio.currentTime
|
||||
lastTimeRef.current = t
|
||||
setCur(t)
|
||||
onProgress(t)
|
||||
// 片尾跳过(legado upPlayProgress:durP >= duration - skipEnds 即当播完)
|
||||
if (
|
||||
closeCredits > 0 &&
|
||||
!skippedEndRef.current &&
|
||||
Number.isFinite(audio.duration) &&
|
||||
audio.duration > closeCredits + 1 &&
|
||||
t >= audio.duration - closeCredits
|
||||
) {
|
||||
skippedEndRef.current = true
|
||||
if (!hasNext) {
|
||||
audio.pause()
|
||||
audio.currentTime = Math.max(0, audio.duration - 0.5)
|
||||
return
|
||||
}
|
||||
onEnded()
|
||||
}
|
||||
}}
|
||||
onEnded={onEnded}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
||||
import { rememberImageSize } from '../../utils/comicSpread'
|
||||
import { comicImageSizing, isSelfSizedComicFit } from '../../utils/comicImageFit'
|
||||
import type { ComicImageFit } from '../../stores/readerSettings'
|
||||
|
||||
// 漫画/图片阅读器(仿 legado MangaMenu 能力面):
|
||||
// 上下滚动(默认)/ 左右单页两种模式;点击分区翻页/呼出菜单;图片懒加载。
|
||||
// 上下滚动(默认)/ 左右单页 / 左右双页三种呈现;点击分区翻页/呼出菜单;图片懒加载。
|
||||
//
|
||||
// 双页铺开由外层算好「这一屏显示哪几张」传进来(见 utils/comicSpread.ts),
|
||||
// 这里只负责把它们并排摆好、各自撑满视口高度。
|
||||
// 滚动模式的显示尺寸由 imageFit 档位决定(见 utils/comicImageFit.ts)。
|
||||
|
||||
interface ReaderComicProps {
|
||||
images: string[]
|
||||
@@ -14,17 +23,30 @@ interface ReaderComicProps {
|
||||
onProgress: (imageIndex: number) => void
|
||||
scrollTo: number | null // 滚动模式:外部要求滚动到的图片序号
|
||||
onScrolled: () => void
|
||||
/** 双页模式:本屏要并排显示的图片序号(1 张=独占的宽图/单页,2 张=左右一对)。 */
|
||||
spread?: number[] | null
|
||||
/** 滚动模式的图片显示尺寸档位(翻页模式不适用,传 default 即可)。 */
|
||||
imageFit?: ComicImageFit
|
||||
}
|
||||
|
||||
function ComicImage({
|
||||
src,
|
||||
theme,
|
||||
fit = false,
|
||||
half = false,
|
||||
imageFit = 'default',
|
||||
viewportHeight = 0,
|
||||
}: {
|
||||
src: string
|
||||
theme: { bg: string; text: string; accent: string }
|
||||
// fit:单页模式用。整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉。
|
||||
// fit:整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉(翻页模式)。
|
||||
fit?: boolean
|
||||
// half:双页并排时占位不超过半屏;容器收窄到图片本身宽度,两页之间不留缝。
|
||||
half?: boolean
|
||||
// imageFit:滚动模式的显示尺寸档位。
|
||||
imageFit?: ComicImageFit
|
||||
// viewportHeight:滚动容器高度(px),height/long 档位按它换算。
|
||||
viewportHeight?: number
|
||||
}) {
|
||||
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
|
||||
|
||||
@@ -34,23 +56,43 @@ function ComicImage({
|
||||
setState('loading')
|
||||
}, [src])
|
||||
|
||||
const loaded = state === 'ok'
|
||||
// 翻页模式:整页缩放进视口。滚动模式:按用户选的尺寸档位摆(外层列宽已放开)。
|
||||
const sizing = fit
|
||||
? {
|
||||
className: 'block max-h-full w-auto max-w-full object-contain',
|
||||
style: loaded ? {} : { minHeight: '10rem' },
|
||||
}
|
||||
: comicImageSizing(imageFit, viewportHeight, loaded)
|
||||
|
||||
// <img> 必须始终留在渲染树里(不能 display:none):浏览器不会去拉取
|
||||
// display:none 的 loading="lazy" 图片,onLoad 就永远不会触发,于是更没机会
|
||||
// 脱离 loading —— 之前用 hidden 藏图就死在这里,漫画只剩一个转圈。
|
||||
// 改成图片正常参与布局,未加载时用 minHeight 占位,转圈/失败信息盖在上层。
|
||||
return (
|
||||
<div
|
||||
className={`relative flex w-full items-center justify-center ${fit ? 'h-full' : ''}`}
|
||||
style={{ backgroundColor: theme.bg }}
|
||||
className={`relative flex items-center justify-center ${fit ? 'h-full' : ''} ${half ? '' : 'w-full'}`}
|
||||
style={{
|
||||
backgroundColor: theme.bg,
|
||||
// 半屏占位:宽度取「图片自然宽度」与「半屏」的较小值。容器是 flex item,
|
||||
// 百分比 max-width 相对这一行的宽度解析,所以图片不会撑破半屏;
|
||||
// 图片比半屏窄时容器跟着收窄,左右两页自然贴在一起。
|
||||
...(half ? { maxWidth: '50%', minWidth: 0 } : null),
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
loading="lazy"
|
||||
alt=""
|
||||
onLoad={() => setState('ok')}
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget
|
||||
// 顺带回填原始尺寸:双页分组要用它判断这张图是否横跨两页
|
||||
rememberImageSize(src, el.naturalWidth, el.naturalHeight)
|
||||
setState('ok')
|
||||
}}
|
||||
onError={() => setState('error')}
|
||||
className={fit ? 'block max-h-full w-auto max-w-full object-contain' : 'block w-full'}
|
||||
style={state === 'ok' ? undefined : { minHeight: '10rem' }}
|
||||
className={sizing.className}
|
||||
style={sizing.style}
|
||||
/>
|
||||
{state === 'loading' && (
|
||||
<div
|
||||
@@ -72,10 +114,27 @@ function ComicImage({
|
||||
)
|
||||
}
|
||||
|
||||
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled }: ReaderComicProps) {
|
||||
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread, imageFit = 'default' }: ReaderComicProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const restoredRef = useRef(false)
|
||||
// 滚动容器的高度:「一屏多高」以这个容器为准,而不是 window.innerHeight——正文区是
|
||||
// root(fixed inset-0)里的 flex-1,将来上下再挂条之类的布局变化就不等于窗口高度了。
|
||||
// 用 ResizeObserver 跟着它,改窗口大小时 height/long 档位自动重算。
|
||||
const [viewportHeight, setViewportHeight] = useState(0)
|
||||
|
||||
const selfSized = isSelfSizedComicFit(imageFit)
|
||||
useEffect(() => {
|
||||
// 只有 height/long/original 用得到,默认档位不挂观察器,免得每次改窗口都重渲染整章。
|
||||
if (mode !== 'scroll' || !selfSized) return
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const update = () => setViewportHeight(el.clientHeight)
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [mode, selfSized])
|
||||
|
||||
// 滚动模式:恢复进度(图片序号)并上报当前图
|
||||
useEffect(() => {
|
||||
@@ -98,6 +157,9 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
||||
restoredRef.current = false
|
||||
}, [mode])
|
||||
|
||||
// 上下滚动模式的滚轮同样走「类手机滑动」的平滑惯性滚动
|
||||
useSmoothWheelScroll(scrollRef, mode === 'scroll')
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm opacity-60" style={{ color: theme.text }}>
|
||||
@@ -107,11 +169,16 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
||||
}
|
||||
|
||||
if (mode === 'page') {
|
||||
// 单页模式:翻页由外层点击区驱动(page 语义 = 图片序号)
|
||||
const idx = Math.min(Math.max(page, 0), images.length - 1)
|
||||
// 翻页模式:翻页由外层点击区驱动(page 语义 = 图片序号)。
|
||||
// 双页铺开时外层按「屏」给好成员(spread),这里只负责并排摆开。
|
||||
const group = (spread ?? []).filter((i) => i >= 0 && i < images.length)
|
||||
const idxs = group.length > 0 ? group : [Math.min(Math.max(page, 0), images.length - 1)]
|
||||
const paired = idxs.length > 1
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<ComicImage key={images[idx]} src={images[idx]} theme={theme} fit />
|
||||
{idxs.map((i) => (
|
||||
<ComicImage key={images[i]} src={images[i]} theme={theme} fit half={paired} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -153,7 +220,7 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ComicImage src={src} theme={theme} />
|
||||
<ComicImage src={src} theme={theme} imageFit={imageFit} viewportHeight={viewportHeight} />
|
||||
<p className="pb-1 text-center text-2xs opacity-40" style={{ color: theme.text }}>
|
||||
{i + 1} / {images.length}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Loader2, Pause, Play, X } from 'lucide-react'
|
||||
|
||||
import { useReaderAudioStore } from '../../stores/readerAudio'
|
||||
import { readerCoverSrc } from '../../utils/readerCover'
|
||||
import { getReaderAudioEngine } from './readerAudioEngine'
|
||||
|
||||
// 全局听书迷你条。
|
||||
//
|
||||
// 播放引擎是常驻单例(见 readerAudioEngine.ts),离开阅读页后音频仍在播;这个条
|
||||
// 保证在其他页面也能暂停/回到阅读,否则用户会「听到声音但找不到控制」。
|
||||
// 面板所在的阅读页内部不显示它(那里有完整播放面板),避免两套控制打架。
|
||||
export function ReaderMiniPlayer() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const track = useReaderAudioStore((s) => s.track)
|
||||
const bookId = useReaderAudioStore((s) => s.bookId)
|
||||
const title = useReaderAudioStore((s) => s.chapterTitle)
|
||||
const bookName = useReaderAudioStore((s) => s.bookName)
|
||||
const cover = useReaderAudioStore((s) => s.cover)
|
||||
const status = useReaderAudioStore((s) => s.status)
|
||||
|
||||
const onReaderView = bookId !== '' && location.pathname === `/reader/view/${bookId}`
|
||||
if (!track || !bookId || onReaderView || status === 'idle') return null
|
||||
|
||||
const engine = getReaderAudioEngine()
|
||||
const playing = status === 'playing'
|
||||
const loading = status === 'loading'
|
||||
const coverSrc = readerCoverSrc(cover)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-x-3 z-[60] flex items-center gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]/95 p-2 shadow-2xl backdrop-blur-md lg:inset-x-auto lg:right-6 lg:w-96"
|
||||
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 3.75rem)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/reader/view/${bookId}`)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||
title="回到阅读页"
|
||||
>
|
||||
<span className="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-[var(--app-brand-soft)]">
|
||||
{coverSrc ? (
|
||||
<img src={coverSrc} alt="" referrerPolicy="no-referrer" className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-bold text-[var(--app-text)]">{title || bookName}</span>
|
||||
<span className="block truncate text-[10px] text-[var(--app-muted)]">{bookName || 'MeBox 听书'}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => engine.toggle()}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500 text-white shadow-sm"
|
||||
aria-label={playing ? '暂停' : '播放'}
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : playing ? <Pause size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => engine.stop()}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[var(--app-muted)] hover:text-[var(--app-text)]"
|
||||
aria-label="停止播放"
|
||||
title="停止播放"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,32 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
|
||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, ListFilter, Loader2, Plus, Search } from 'lucide-react'
|
||||
|
||||
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped } from '../../api/reader'
|
||||
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped, type ReaderSource } from '../../api/reader'
|
||||
import ReaderBookCover from '../../components/ReaderBookCover'
|
||||
import { useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { splitKindTags } from '../../utils/kindTags'
|
||||
import { SearchScopeDialog } from './SearchScopeDialog'
|
||||
import { SourcePickerDialog } from './SourcePickerDialog'
|
||||
|
||||
// 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。
|
||||
// 搜索前可按书源收敛范围(对应 legado 的搜索范围):默认全选已启用书源。
|
||||
// 搜索结果会剔除已在书架里的书——这里的搜索是用来发现新书的。
|
||||
|
||||
/** 书架命中键:与搜索结果一致按「书名 + 作者」聚合;作者缺省时只用书名(同 legado isInBookShelf)。 */
|
||||
function shelfBookKey(name: string, author: string): string {
|
||||
const n = name.trim()
|
||||
const a = author.trim()
|
||||
return a ? `${n}|${a}` : n
|
||||
}
|
||||
|
||||
/** 这本书是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */
|
||||
function isOnShelf(book: ReaderSearchBook, shelf: Set<string>): boolean {
|
||||
if (shelf.has(shelfBookKey(book.name, book.author))) return true
|
||||
if (book.book_url && shelf.has(book.book_url)) return true
|
||||
return book.origins.some((o) => !!o.book_url && shelf.has(o.book_url))
|
||||
}
|
||||
|
||||
export default function ReaderSearchPage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -23,13 +41,60 @@ export default function ReaderSearchPage() {
|
||||
const [adding, setAdding] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 搜索范围(设备级持久化,见 readerSettings store):空数组 = 全部启用书源。
|
||||
const scopeIds = useReaderSettingsStore((s) => s.searchScopeIds)
|
||||
const setSearchScopeIds = useReaderSettingsStore((s) => s.setSearchScopeIds)
|
||||
const [sources, setSources] = useState<ReaderSource[]>([])
|
||||
const [showScope, setShowScope] = useState(false)
|
||||
const enabledSources = useMemo(() => sources.filter((s) => s.enabled), [sources])
|
||||
// 已保存范围里仍有效的书源(删源/停源后自动剔除,全失效时等同于全部)。
|
||||
const activeScopeIds = useMemo(
|
||||
() => scopeIds.filter((id) => enabledSources.some((s) => s.id === id)),
|
||||
[scopeIds, enabledSources],
|
||||
)
|
||||
// 书源列表还没加载完(sources 为空)时不能判断有效性,先按已存 ID 数显示,
|
||||
// 避免已保存子集在加载瞬间被错标成「全部书源」。
|
||||
const scopeAll = scopeIds.length === 0 || (sources.length > 0 && activeScopeIds.length === 0)
|
||||
const scopeLabel = scopeAll ? '全部书源' : `已选 ${activeScopeIds.length || scopeIds.length} 个书源`
|
||||
|
||||
useEffect(() => {
|
||||
readerAPI.listSources().then(setSources).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
// 书架命中集合:已在书架的书不参与结果展示(这里只找新书)。
|
||||
const [shelfKeys, setShelfKeys] = useState<Set<string>>(() => new Set())
|
||||
useEffect(() => {
|
||||
readerAPI
|
||||
.listBooks()
|
||||
.then((books) => {
|
||||
const keys = new Set<string>()
|
||||
for (const b of books) {
|
||||
keys.add(shelfBookKey(b.name, b.author))
|
||||
if (b.book_url) keys.add(b.book_url)
|
||||
}
|
||||
setShelfKeys(keys)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
// 展示用结果 = 原始结果剔除已在书架的书。用派生值而不是在 doSearch 里过滤:
|
||||
// 书架是异步加载的,派生能保证「书架先到还是结果先到」都能正确隐藏,
|
||||
// 且刚加进书架的书也会立刻从列表消失。
|
||||
const visibleBooks = useMemo(
|
||||
() => (books === null ? null : books.filter((b) => !isOnShelf(b, shelfKeys))),
|
||||
[books, shelfKeys],
|
||||
)
|
||||
const hiddenOnShelf = books === null ? 0 : books.length - (visibleBooks?.length ?? 0)
|
||||
|
||||
const doSearch = async () => {
|
||||
const kw = key.trim()
|
||||
if (!kw || searching) return
|
||||
setSearching(true)
|
||||
setShowSkipped(false)
|
||||
try {
|
||||
const res = await readerAPI.search(kw)
|
||||
// 直接把已保存范围交给后端:它只搜其中仍启用的书源,全部失效时退回全部启用,
|
||||
// 因此这里不必等书源列表加载完,也能正确处理「换设备后书源尚未同步」的情况。
|
||||
const res = await readerAPI.search(kw, scopeIds)
|
||||
setBooks(res.books ?? [])
|
||||
// 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组,
|
||||
// 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。
|
||||
@@ -52,12 +117,20 @@ export default function ReaderSearchPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const addToShelf = async (book: ReaderSearchBook) => { const origin = book.origins[0]
|
||||
const addToShelf = async (book: ReaderSearchBook) => {
|
||||
const origin = book.origins[0]
|
||||
if (!origin) return
|
||||
setAdding(book.book_url)
|
||||
try {
|
||||
await readerAPI.addBook({ origin, name: book.name, author: book.author, cover_url: book.cover_url })
|
||||
toast.success(`《${book.name}》已加入书架`)
|
||||
// 立刻并入书架命中集合:这本「刚加的书」应从「搜索新书」的结果里消失。
|
||||
setShelfKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(shelfBookKey(book.name, book.author))
|
||||
if (origin.book_url) next.add(origin.book_url)
|
||||
return next
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加入书架失败'
|
||||
toast.error(msg)
|
||||
@@ -113,6 +186,22 @@ export default function ReaderSearchPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索范围:默认「全部书源」(全选已启用),点开可收敛到指定书源 */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowScope(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-1.5 text-2xs font-bold text-[var(--app-muted)] transition hover:border-brand-500/40 hover:text-brand-600"
|
||||
>
|
||||
<ListFilter size={13} />
|
||||
搜索范围
|
||||
<span className="text-brand-600">{scopeLabel}</span>
|
||||
</button>
|
||||
{scopeAll && enabledSources.length > 0 && (
|
||||
<span className="text-2xs text-[var(--app-subtle)]">共 {enabledSources.length} 个启用书源</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{searching && (
|
||||
<div className="flex flex-col items-center gap-2 py-24 text-[var(--app-muted)]">
|
||||
<Loader2 className="animate-spin" size={22} />
|
||||
@@ -120,9 +209,19 @@ export default function ReaderSearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searching && books !== null && (
|
||||
{!searching && visibleBooks !== null && (
|
||||
<div className="mt-6 space-y-3">
|
||||
{books.map((book) => (
|
||||
{hiddenOnShelf > 0 && visibleBooks.length > 0 && (
|
||||
<p className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-2xs text-[var(--app-muted)]">
|
||||
已隐藏 {hiddenOnShelf} 本已在书架里的书
|
||||
</p>
|
||||
)}
|
||||
{visibleBooks.length === 0 && (
|
||||
<p className="py-16 text-center text-xs text-[var(--app-muted)]">
|
||||
{hiddenOnShelf > 0 ? '搜到的书都已经在书架里了' : '没有找到结果'}
|
||||
</p>
|
||||
)}
|
||||
{visibleBooks.map((book) => (
|
||||
<div
|
||||
key={`${book.name}|${book.author}`}
|
||||
className="flex gap-4 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4"
|
||||
@@ -225,6 +324,18 @@ export default function ReaderSearchPage() {
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showScope && (
|
||||
<SearchScopeDialog
|
||||
sources={sources}
|
||||
selectedIds={scopeIds}
|
||||
onConfirm={(ids) => {
|
||||
setSearchScopeIds(ids)
|
||||
setShowScope(false)
|
||||
}}
|
||||
onClose={() => setShowScope(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ArrowLeft,
|
||||
ArrowLeftRight,
|
||||
BookOpen,
|
||||
Columns2,
|
||||
LayoutList,
|
||||
ListEnd,
|
||||
Loader2,
|
||||
@@ -17,11 +18,15 @@ import {
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
|
||||
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
|
||||
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { useComicSpreads } from '../../hooks/useComicSpreads'
|
||||
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
||||
import { useReaderAudioStore } from '../../stores/readerAudio'
|
||||
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
|
||||
import { ReaderAudioPanel } from './ReaderAudioPanel'
|
||||
import { ReaderComic } from './ReaderComic'
|
||||
import { SourcePickerDialog } from './SourcePickerDialog'
|
||||
import { getReaderAudioEngine } from './readerAudioEngine'
|
||||
|
||||
// 文本阅读器(仿 legado ReadBookActivity:主题配色、点击区域、上下章、
|
||||
// 进度记忆、翻页/滚动双模式;桌面端限宽居中,支持键盘翻页)。
|
||||
@@ -49,6 +54,15 @@ const WHEEL_TURN_COOLDOWN = 220
|
||||
/** 滚轮事件间隔超过这个毫秒数算新手势,重新累计(用于判断触控板一次滑动结束)。 */
|
||||
const WHEEL_GESTURE_GAP = 180
|
||||
|
||||
/**
|
||||
* 漫画双页铺开的最小窗口宽度。比这窄时并排两页每页只剩一条竖条,
|
||||
* 反而比单页更难看清,所以渲染层按窗口宽度自动退回单页(偏好设置不动)。
|
||||
*/
|
||||
const COMIC_SPREAD_MIN_WIDTH = 900
|
||||
|
||||
/** 空图片列表的常量引用:避免每次渲染产生新数组、把分组结果的 memo 依赖打散。 */
|
||||
const NO_IMAGES: string[] = []
|
||||
|
||||
function firstReadableIndex(chapters: ReaderChapter[]): number {
|
||||
const i = chapters.findIndex((c) => !c.is_volume && c.url)
|
||||
if (i !== -1) return i
|
||||
@@ -109,6 +123,47 @@ export default function ReaderViewPage() {
|
||||
const pendingPosRef = useRef(0)
|
||||
const pendingEndRef = useRef(false)
|
||||
|
||||
// ── 漫画双页铺开(桌面端) ──
|
||||
// 窗口宽度决定双页是否可用:偏好在 settings 里(用户可关),但窗口不够宽时
|
||||
// 即使开着也退回单页,所以这里额外跟一个窗口宽度。
|
||||
const [windowWidth, setWindowWidth] = useState(() => window.innerWidth)
|
||||
useEffect(() => {
|
||||
const onResize = () => setWindowWidth(window.innerWidth)
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [])
|
||||
|
||||
const comicImages = contentType === 'image' && media?.images ? media.images : NO_IMAGES
|
||||
const comicDoublePage =
|
||||
contentType === 'image' &&
|
||||
settings.pageMode === 'page' &&
|
||||
settings.comicDoublePage &&
|
||||
windowWidth >= COMIC_SPREAD_MIN_WIDTH
|
||||
// 分组依赖每张图的原始宽高(横跨两页的宽图要独占一屏),探测窗口跟着当前页走
|
||||
const { spreads: comicSpreads, spreadIndexOf: comicSpreadIndexOf } = useComicSpreads(
|
||||
comicImages,
|
||||
comicDoublePage,
|
||||
comicPage,
|
||||
)
|
||||
/** 当前是第几「屏」。进度仍按图片序号记,屏序号只用于翻页与渲染。 */
|
||||
const comicSpreadIndex =
|
||||
comicDoublePage && comicSpreads.length > 0
|
||||
? Math.min(comicSpreadIndexOf[comicPage] ?? 0, comicSpreads.length - 1)
|
||||
: 0
|
||||
|
||||
// ── 漫画滚动模式的图片显示尺寸 ──
|
||||
// 档位只在上下滚动模式生效:翻页模式是整页缩放进视口,没有「太大/太小」的问题,
|
||||
// 掺进来反而会和双页铺开的排版打架。所以翻页模式一律按 default 处理。
|
||||
const comicScrollFit = contentType === 'image' && settings.pageMode === 'scroll' ? settings.comicImageFit : 'default'
|
||||
/** 图片尺寸档位只在漫画的滚动模式下起作用,别的场景不显示这一行免得点了没反应。 */
|
||||
const showComicImageFit = contentType === 'image' && settings.pageMode === 'scroll'
|
||||
/**
|
||||
* 正文列是否放开 900px 上限。两种情况:漫画双页铺开要吃满窗口宽度;
|
||||
* 或者用户把图片尺寸调成了「适应宽度/高度/长边/原图」——这些档位由图片自己定尺寸,
|
||||
* 900px 的列会把它们再压回去。
|
||||
*/
|
||||
const comicFullWidth = contentType === 'image' && (comicDoublePage || comicScrollFit !== 'default')
|
||||
|
||||
// ── 加载书籍与章节 ──
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -140,11 +195,28 @@ export default function ReaderViewPage() {
|
||||
const qChapterRaw = new URLSearchParams(window.location.search).get('chapter')
|
||||
const qChapter = qChapterRaw && qChapterRaw.trim() !== '' ? Number(qChapterRaw) : NaN
|
||||
let idx = firstReadableIndex(chs)
|
||||
if (Number.isInteger(qChapter) && chs[qChapter] && !chs[qChapter].is_volume) idx = qChapter
|
||||
const explicitChapter = Number.isInteger(qChapter) && !!chs[qChapter] && !chs[qChapter].is_volume
|
||||
if (explicitChapter) idx = qChapter
|
||||
else if (b.dur_chapter_index > 0 && chs[b.dur_chapter_index] && !chs[b.dur_chapter_index].is_volume) {
|
||||
idx = b.dur_chapter_index
|
||||
}
|
||||
pendingPosRef.current = b.dur_chapter_pos ?? 0
|
||||
// 听书:引擎是常驻的,可能已经自动播到更靠后的章,而服务端的进度还停在
|
||||
// 上一次落库的位置。这种「续听」场景要以引擎的实时章节为准,否则回到本书
|
||||
// 会把正在播的章节往回拽。URL 显式指定 chapter 时尊重 URL(那是明确的跳转)。
|
||||
const live = useReaderAudioStore.getState()
|
||||
if (
|
||||
!explicitChapter &&
|
||||
live.bookId === b.id &&
|
||||
live.track !== '' &&
|
||||
(live.status === 'playing' || live.status === 'loading' || live.status === 'paused') &&
|
||||
live.chapterIndex !== idx &&
|
||||
chs[live.chapterIndex]
|
||||
) {
|
||||
idx = live.chapterIndex
|
||||
// 位置由引擎自己掌握,服务端那份属于上一章,别写过去
|
||||
pendingPosRef.current = 0
|
||||
}
|
||||
setChapterIndex(idx)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message || '加载失败')
|
||||
@@ -187,6 +259,22 @@ export default function ReaderViewPage() {
|
||||
setRestorePos(savedPos)
|
||||
pendingPosRef.current = 0
|
||||
}
|
||||
// 听书:把这一章交给常驻播放引擎。引擎是全局单例,离开本页也不会被卸载,
|
||||
// 因此换章/离开路由/息屏都继续播(面板卸载不再掐播放)。
|
||||
if (ct.type === 'audio' && ct.tracks && ct.tracks.length > 0) {
|
||||
getReaderAudioEngine().loadChapter({
|
||||
bookId: book.id,
|
||||
bookName: book.name,
|
||||
cover: book.cover_url ?? '',
|
||||
chapters,
|
||||
chapterIndex,
|
||||
track: ct.tracks[0],
|
||||
transcoding: ct.transcoding ?? false,
|
||||
openCredits: book.open_credits ?? 0,
|
||||
closeCredits: book.close_credits ?? 0,
|
||||
initialPos: savedPos,
|
||||
})
|
||||
}
|
||||
// 进度上报(文本的 pos 保留原值,排版完成后才被消费清零)。
|
||||
// 注意用 savedPos:非文本在上面已经把 pendingPosRef 清零了,直接读会把
|
||||
// 刚恢复的进度又写成 0,退出重进就从头开始。
|
||||
@@ -259,6 +347,11 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
}, [content, settings.pageMode])
|
||||
|
||||
// 滚动模式的滚轮改成「类手机滑动」:把滚轮格数累加成目标位置再逐帧逼近,
|
||||
// 滚动连续、松手后自己滑行一段,而不是浏览器整格跳变。
|
||||
// 翻页模式的滚轮由下面的翻页监听器接管,这里必须关掉。
|
||||
useSmoothWheelScroll(scrollRef, settings.pageMode === 'scroll' && contentType === 'text')
|
||||
|
||||
// ── 进度保存(翻页 / 滚动 / 听书 / 漫画) ──
|
||||
// pos 对文本是页码、漫画是图片序号、听书是秒;audio.currentTime 带小数,
|
||||
// 统一取整后再上报,和 dur_chapter_pos 的 int 语义对齐。
|
||||
@@ -373,14 +466,17 @@ export default function ReaderViewPage() {
|
||||
[chapterIndex, chapters.length, panel],
|
||||
)
|
||||
|
||||
// 听书:是否还有下一章(片尾跳过/播完时决定续播还是停住)
|
||||
const hasNextAudioChapter = useMemo(() => {
|
||||
if (chapterIndex === null) return false
|
||||
for (let i = chapterIndex + 1; i < chapters.length; i++) {
|
||||
if (!chapters[i].is_volume) return true
|
||||
}
|
||||
return false
|
||||
}, [chapterIndex, chapters])
|
||||
// 听书:引擎自己也可能切章(播完自动下一章、锁屏/耳机按键上一章下一章),把它的
|
||||
// 当前章节回写进本页,目录与菜单才不会停在旧章节上。只认本书,避免串到别的书。
|
||||
const audioChapterIndex = useReaderAudioStore((s) =>
|
||||
book && s.bookId === book.id ? s.chapterIndex : null,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (contentType !== 'audio' || audioChapterIndex === null) return
|
||||
if (audioChapterIndex === chapterIndex) return
|
||||
pendingEndRef.current = false
|
||||
setChapterIndex(audioChapterIndex)
|
||||
}, [audioChapterIndex, chapterIndex, contentType])
|
||||
|
||||
// ── 换源(对应 legado 阅读页的「换源」) ──
|
||||
//
|
||||
@@ -430,22 +526,20 @@ export default function ReaderViewPage() {
|
||||
// 本地导入的书没有书源,不显示换源入口
|
||||
const canSwitchSource = !!book && !book.is_local
|
||||
|
||||
// 听书:片头/片尾跳过秒数按书写入(对应 legado Book.openCredits/closeCredits)
|
||||
const saveAudioCredits = useCallback(
|
||||
(open: number, close: number) => {
|
||||
if (!book) return
|
||||
setBook((prev) => (prev ? { ...prev, open_credits: open, close_credits: close } : prev))
|
||||
readerAPI.saveAudioConfig(book.id, { open_credits: open, close_credits: close }).catch(() => undefined)
|
||||
},
|
||||
[book],
|
||||
)
|
||||
// 听书:片头/片尾跳过秒数的落库改由播放引擎负责(它持有当前书 ID,离开本页也能写)。
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (contentType === 'audio') {
|
||||
goChapter(-1)
|
||||
getReaderAudioEngine().prev()
|
||||
return
|
||||
}
|
||||
if (contentType === 'image' && settings.pageMode === 'page') {
|
||||
// 双页:一次退一屏;进度落在该屏的首张图上
|
||||
if (comicDoublePage && comicSpreads.length > 0) {
|
||||
if (comicSpreadIndex > 0) setComicPage(comicSpreads[comicSpreadIndex - 1][0])
|
||||
else goChapter(-1, true)
|
||||
return
|
||||
}
|
||||
if (comicPage > 0) setComicPage((p) => p - 1)
|
||||
else goChapter(-1, true)
|
||||
return
|
||||
@@ -456,14 +550,19 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
if (page > 0) setPage((p) => p - 1)
|
||||
else goChapter(-1, true)
|
||||
}, [contentType, settings.pageMode, comicPage, page, goChapter])
|
||||
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, comicPage, page, goChapter])
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (contentType === 'audio') {
|
||||
goChapter(1)
|
||||
getReaderAudioEngine().next()
|
||||
return
|
||||
}
|
||||
if (contentType === 'image' && settings.pageMode === 'page') {
|
||||
if (comicDoublePage && comicSpreads.length > 0) {
|
||||
if (comicSpreadIndex < comicSpreads.length - 1) setComicPage(comicSpreads[comicSpreadIndex + 1][0])
|
||||
else goChapter(1)
|
||||
return
|
||||
}
|
||||
const n = media?.images?.length ?? 0
|
||||
if (comicPage < n - 1) setComicPage((p) => p + 1)
|
||||
else goChapter(1)
|
||||
@@ -477,7 +576,22 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
if (page < pageCount - 1) setPage((p) => p + 1)
|
||||
else goChapter(1)
|
||||
}, [contentType, settings.pageMode, media, comicPage, page, pageCount, goChapter])
|
||||
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, media, comicPage, page, pageCount, goChapter])
|
||||
|
||||
// ── 点击分区(左 30% 上一页 / 中间呼出菜单 / 右 30% 下一页) ──
|
||||
// 翻页模式用覆盖层上的三个按钮驱动;滚动模式不能再用覆盖层——
|
||||
// 覆盖层不是滚动容器的子节点,手机上手指落在它上面会让纵向滑动失效
|
||||
// (只能点左右),所以滚动模式改在滚动容器自身的 onClick 上按 x 坐标分区:
|
||||
// 纵向拖动不会产生 click,浏览器原生滚动照常工作。
|
||||
const handleZoneTap = useCallback(
|
||||
(clientX: number, rect: DOMRect) => {
|
||||
const x = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5
|
||||
if (x < 0.3) goPrev()
|
||||
else if (x > 0.7) goNext()
|
||||
else setMenuOpen((v) => !v)
|
||||
},
|
||||
[goPrev, goNext],
|
||||
)
|
||||
|
||||
// ── 键盘(桌面端) ──
|
||||
useEffect(() => {
|
||||
@@ -499,12 +613,13 @@ export default function ReaderViewPage() {
|
||||
}, [goPrev, goNext, menuOpen, panel])
|
||||
|
||||
// ── 鼠标滚轮翻页(仅翻页模式) ──
|
||||
// 往上滚=上一页,往下滚=下一页。滚动模式保持浏览器原生滚动,不做接管。
|
||||
// 往上滚=上一页,往下滚=下一页。滚动模式的滚轮不在这里处理,
|
||||
// 由 useSmoothWheelScroll 接管做平滑惯性滚动。
|
||||
// 滚轮必须用原生监听器:React 的 onWheel 是 passive 的,调不了 preventDefault。
|
||||
useEffect(() => {
|
||||
const el = readerRef.current
|
||||
if (!el) return
|
||||
// 滚动模式/听书面板/漫画滚动交给浏览器自己处理;菜单打开时不翻页
|
||||
// 滚动模式/听书面板/漫画滚动交给各自的滚动逻辑;菜单打开时不翻页
|
||||
if (settings.pageMode !== 'page' || menuOpen) return
|
||||
if (contentType !== 'text' && contentType !== 'image') return
|
||||
|
||||
@@ -639,30 +754,10 @@ export default function ReaderViewPage() {
|
||||
<div ref={readerRef} className="fixed inset-0 z-40 flex flex-col" style={{ backgroundColor: theme.bg, color: theme.text }}>
|
||||
{/* 正文视口 */}
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<div className="mx-auto h-full w-full max-w-[900px]">
|
||||
<div className={`mx-auto h-full w-full ${comicFullWidth ? '' : 'max-w-[900px]'}`}>
|
||||
{contentType === 'audio' ? (
|
||||
media && media.tracks && media.tracks.length > 0 ? (
|
||||
<ReaderAudioPanel
|
||||
src={media.tracks[0]}
|
||||
title={currentChapter?.title ?? book?.name ?? '播放'}
|
||||
cover={book?.cover_url ?? ''}
|
||||
theme={theme}
|
||||
initialPos={restorePos}
|
||||
openCredits={book?.open_credits ?? 0}
|
||||
closeCredits={book?.close_credits ?? 0}
|
||||
chapters={chapters}
|
||||
chapterIndex={chapterIndex}
|
||||
hasNext={hasNextAudioChapter}
|
||||
transcoding={media.transcoding ?? false}
|
||||
onProgress={throttledMediaSave}
|
||||
onCommitProgress={savePos}
|
||||
onPrevChapter={() => goChapter(-1)}
|
||||
onNextChapter={() => goChapter(1)}
|
||||
onSelectChapter={jumpToChapter}
|
||||
onEnded={() => goChapter(1)}
|
||||
onCreditsChange={saveAudioCredits}
|
||||
onToggleMenu={() => setMenuOpen((v) => !v)}
|
||||
/>
|
||||
<ReaderAudioPanel theme={theme} 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} /> : '本章没有可播放的音频'}
|
||||
@@ -670,10 +765,12 @@ export default function ReaderViewPage() {
|
||||
)
|
||||
) : contentType === 'image' ? (
|
||||
<ReaderComic
|
||||
images={media?.images ?? []}
|
||||
images={comicImages}
|
||||
theme={theme}
|
||||
mode={settings.pageMode}
|
||||
page={comicPage}
|
||||
imageFit={comicScrollFit}
|
||||
spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null}
|
||||
onZone={(zone) => {
|
||||
if (zone === 'center') setMenuOpen((v) => !v)
|
||||
}}
|
||||
@@ -711,6 +808,7 @@ export default function ReaderViewPage() {
|
||||
) : (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onClick={(e) => handleZoneTap(e.clientX, e.currentTarget.getBoundingClientRect())}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget
|
||||
if (chapterIndex === null) return
|
||||
@@ -723,7 +821,7 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`h-full overflow-y-auto px-4 ${MENU_SHIFT}`}
|
||||
className={`h-full overflow-y-auto overscroll-contain px-4 ${MENU_SHIFT}`}
|
||||
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight, ...menuShiftStyle }}
|
||||
>
|
||||
<div className="py-4">
|
||||
@@ -763,7 +861,10 @@ export default function ReaderViewPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(contentType === 'text' || (contentType === 'image' && settings.pageMode === 'page')) && (
|
||||
{/* 点击分区覆盖层:只在翻页模式用。滚动模式用覆盖层会挡住正文的
|
||||
原生纵向滚动(手指落在覆盖层上时找不到可滚动的祖先节点),
|
||||
所以滚动模式由正文容器的 onClick 分区(见 handleZoneTap)。 */}
|
||||
{settings.pageMode === 'page' && (contentType === 'text' || contentType === 'image') && (
|
||||
<div className="absolute inset-0 grid grid-cols-[30%_40%_30%]">
|
||||
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
|
||||
<button
|
||||
@@ -846,8 +947,10 @@ export default function ReaderViewPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 动作行(目录 / 界面 / 夜间 / 模式 / 换源) */}
|
||||
<div className={`${canSwitchSource ? 'grid-cols-5' : 'grid-cols-4'} grid pt-1`} style={{ color: theme.text }}>
|
||||
{/* 动作行(目录 / 界面 / 夜间 / 模式 / 双页 / 换源)。
|
||||
用 flex 等分而不是 grid-cols-N:动作数量随内容类型和书源能力变化,
|
||||
grid 列数得写死成字面量类名,多一个按钮就要再加一档。 */}
|
||||
<div className="flex pt-1" style={{ color: theme.text }}>
|
||||
{([
|
||||
{ icon: <LayoutList size={18} />, label: '目录', action: () => openPanel('toc') },
|
||||
{ icon: <BookOpen size={18} />, label: '界面', action: () => openPanel('style') },
|
||||
@@ -861,6 +964,17 @@ export default function ReaderViewPage() {
|
||||
label: settings.pageMode === 'page' ? '滚动' : '翻页',
|
||||
action: () => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page'),
|
||||
},
|
||||
// 双页铺开只对漫画的翻页模式有意义;窗口太窄时并排两页没法看,
|
||||
// 干脆不显示入口(不是灰掉,避免用户以为坏了)。
|
||||
...(contentType === 'image' && settings.pageMode === 'page' && windowWidth >= COMIC_SPREAD_MIN_WIDTH
|
||||
? [
|
||||
{
|
||||
icon: <Columns2 size={18} />,
|
||||
label: settings.comicDoublePage ? '单页' : '双页',
|
||||
action: () => settings.setComicDoublePage(!settings.comicDoublePage),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canSwitchSource
|
||||
? [
|
||||
{
|
||||
@@ -877,7 +991,7 @@ export default function ReaderViewPage() {
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={item.action}
|
||||
className="flex flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
|
||||
className="flex flex-1 flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
|
||||
>
|
||||
{item.icon}
|
||||
<span className="text-2xs">{item.label}</span>
|
||||
@@ -885,7 +999,7 @@ export default function ReaderViewPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 界面设置面板(主题 / 字号 / 行距 / 段距) */}
|
||||
{/* 界面设置面板(主题 / 字号 / 行距 / 段距 / 漫画图片尺寸) */}
|
||||
{panel === 'style' && (
|
||||
<div
|
||||
className="absolute bottom-full inset-x-0 border-t px-4 py-4"
|
||||
@@ -939,6 +1053,30 @@ export default function ReaderViewPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 漫画滚动模式的图片显示尺寸。默认档就是老样子(900px 居中),
|
||||
「适应宽度」铺满窗口,「原图」按原始像素 1:1 看细节。 */}
|
||||
{showComicImageFit && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="opacity-70">图片尺寸</span>
|
||||
{COMIC_IMAGE_FITS.map((item) => {
|
||||
const active = settings.comicImageFit === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => settings.setComicImageFit(item.id)}
|
||||
className={`rounded-lg border px-2 py-0.5 ${active ? 'font-bold' : 'opacity-70'}`}
|
||||
style={{
|
||||
borderColor: active ? theme.accent : theme.text + '44',
|
||||
color: active ? theme.accent : theme.text,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Check, Minus, Search, X } from 'lucide-react'
|
||||
|
||||
import type { ReaderSource } from '../../api/reader'
|
||||
|
||||
/**
|
||||
* SearchScopeDialog 搜索范围弹窗(对应 legado 搜索页的 SearchScopeDialog)。
|
||||
*
|
||||
* 只列出书源维度:默认全选所有「已启用」书源(对应 legado SearchScope("") 的
|
||||
* 「全部书源」语义),取消勾选即可把本次搜索收敛到指定书源。已停用的书源会显示
|
||||
* 但不可勾选,让用户知道它们不会被搜索。分组标题支持整组勾选/取消(kind 批量选择)。
|
||||
*
|
||||
* 确认时若「全部启用书源」都被选中,回传空数组表示「全部」——与后端
|
||||
* enabledSourcesForScope 的空范围语义一致,也让后续新增的书源自动纳入搜索。
|
||||
*/
|
||||
export function SearchScopeDialog({
|
||||
sources,
|
||||
selectedIds,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
sources: ReaderSource[]
|
||||
/** 已保存的搜索范围;空数组 = 全部启用书源(默认全选)。 */
|
||||
selectedIds: string[]
|
||||
onConfirm: (ids: string[]) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const enabled = useMemo(() => sources.filter((s) => s.enabled), [sources])
|
||||
const enabledIds = useMemo(() => enabled.map((s) => s.id), [enabled])
|
||||
|
||||
// 初始选中:保存过范围就用它(过滤掉已删除/停用的),否则默认全选已启用。
|
||||
const [selected, setSelected] = useState<Set<string>>(() => {
|
||||
const valid = selectedIds.filter((id) => enabledIds.includes(id))
|
||||
return new Set(valid.length > 0 ? valid : enabledIds)
|
||||
})
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
const keyword = filter.trim().toLowerCase()
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
sources.filter(
|
||||
(s) => !keyword || s.name.toLowerCase().includes(keyword) || (s.group ?? '').toLowerCase().includes(keyword),
|
||||
),
|
||||
[sources, keyword],
|
||||
)
|
||||
// 按分组聚合并保持书源原顺序(ListSources 已按 customOrder 排好)。
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, ReaderSource[]>()
|
||||
for (const s of visible) {
|
||||
const key = (s.group ?? '').trim() || '未分组'
|
||||
const list = map.get(key)
|
||||
if (list) list.push(s)
|
||||
else map.set(key, [s])
|
||||
}
|
||||
return [...map.entries()].map(([name, items]) => ({ name, items }))
|
||||
}, [visible])
|
||||
|
||||
const pickedCount = enabledIds.filter((id) => selected.has(id)).length
|
||||
const allSelected = pickedCount === enabledIds.length && enabledIds.length > 0
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const toggleGroup = (ids: string[]) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
const allIn = ids.every((id) => next.has(id))
|
||||
ids.forEach((id) => (allIn ? next.delete(id) : next.add(id)))
|
||||
return next
|
||||
})
|
||||
|
||||
// 全选/清空只作用于当前可见(可能被搜索词过滤)的已启用书源。
|
||||
const visibleEnabledIds = visible.filter((s) => s.enabled).map((s) => s.id)
|
||||
const selectVisible = () => setSelected((prev) => new Set([...prev, ...visibleEnabledIds]))
|
||||
const clearVisible = () =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
visibleEnabledIds.forEach((id) => next.delete(id))
|
||||
return next
|
||||
})
|
||||
|
||||
const confirm = () => {
|
||||
const picked = enabledIds.filter((id) => selected.has(id))
|
||||
// 全选(含边界:启用源为空)→ 空数组 = 全部,新增书源自动纳入。
|
||||
onConfirm(picked.length === enabledIds.length ? [] : picked)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-bold text-[var(--app-text)]">搜索范围</h3>
|
||||
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">
|
||||
默认搜索全部启用书源 · 已启用 {enabledIds.length} 个
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-b border-[var(--app-border)] px-4 py-3">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-1.5">
|
||||
<Search size={13} className="shrink-0 text-[var(--app-muted)]" />
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="过滤书源名 / 分组"
|
||||
className="w-full bg-transparent text-xs text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={selectVisible} className="btn-outline text-2xs">
|
||||
全选
|
||||
</button>
|
||||
<button type="button" onClick={clearVisible} className="btn-outline text-2xs">
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[160px] flex-1 overflow-y-auto p-2">
|
||||
{sources.length === 0 ? (
|
||||
<p className="py-12 text-center text-xs text-[var(--app-muted)]">还没有书源,先去书源管理导入</p>
|
||||
) : groups.length === 0 ? (
|
||||
<p className="py-12 text-center text-xs text-[var(--app-muted)]">没有匹配「{filter.trim()}」的书源</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{groups.map((group) => {
|
||||
const groupEnabledIds = group.items.filter((s) => s.enabled).map((s) => s.id)
|
||||
const groupChecked = groupEnabledIds.length > 0 && groupEnabledIds.every((id) => selected.has(id))
|
||||
const groupPartial = !groupChecked && groupEnabledIds.some((id) => selected.has(id))
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={groupEnabledIds.length === 0}
|
||||
onClick={() => toggleGroup(groupEnabledIds)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left hover:bg-[var(--app-hover)] disabled:cursor-default"
|
||||
>
|
||||
<CheckMark checked={groupChecked} partial={groupPartial} disabled={groupEnabledIds.length === 0} />
|
||||
<span className="min-w-0 flex-1 truncate text-2xs font-bold uppercase tracking-wide text-[var(--app-muted)]">
|
||||
{group.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-2xs text-[var(--app-subtle)]">{group.items.length}</span>
|
||||
</button>
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{group.items.map((src) => (
|
||||
<button
|
||||
key={src.id}
|
||||
type="button"
|
||||
disabled={!src.enabled}
|
||||
onClick={() => toggle(src.id)}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition hover:bg-[var(--app-hover)] disabled:cursor-default disabled:opacity-50"
|
||||
>
|
||||
<CheckMark checked={src.enabled && selected.has(src.id)} disabled={!src.enabled} />
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-[var(--app-text)]">{src.name}</span>
|
||||
{!src.enabled && (
|
||||
<span className="shrink-0 rounded bg-[var(--app-hover)] px-1.5 py-0.5 text-2xs font-bold text-[var(--app-muted)]">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-[var(--app-border)] px-5 py-3">
|
||||
<span className="flex-1 text-2xs text-[var(--app-muted)]">
|
||||
{allSelected ? '全部书源' : `已选 ${pickedCount} / ${enabledIds.length} 个书源`}
|
||||
</span>
|
||||
<button type="button" onClick={onClose} className="btn-outline text-xs">
|
||||
取消
|
||||
</button>
|
||||
<button type="button" onClick={confirm} disabled={pickedCount === 0} className="btn-primary text-xs disabled:opacity-50">
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 勾选标记(纯展示,点击由外层按钮承载,避免交互元素嵌套)。 */
|
||||
function CheckMark({ checked, partial = false, disabled = false }: { checked: boolean; partial?: boolean; disabled?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition ${
|
||||
checked || partial ? 'border-brand-500 bg-brand-500 text-white' : 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
|
||||
} ${disabled ? 'opacity-40' : ''}`}
|
||||
>
|
||||
{checked && <Check size={11} strokeWidth={3} />}
|
||||
{!checked && partial && <Minus size={11} strokeWidth={3} />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
// 听书播放引擎:模块级单例,持有真正的 <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
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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.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
|
||||
this.detachSource()
|
||||
this.setSessionMetadata(src)
|
||||
if (!src) {
|
||||
this.fail('本章没有可播放的音频')
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
private detachSource(): void {
|
||||
this.detaching = true
|
||||
this.hls?.destroy()
|
||||
this.hls = null
|
||||
this.audio.removeAttribute('src')
|
||||
this.audio.load()
|
||||
this.detaching = false
|
||||
}
|
||||
|
||||
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
|
||||
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) {
|
||||
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()
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
import type { ReaderChapter } from '../api/reader'
|
||||
|
||||
// 听书全局播放状态(刻意不持久化)。
|
||||
//
|
||||
// 真正的播放器是模块级单例(见 pages/reader/readerAudioEngine.ts),React 组件只
|
||||
// 读写这份状态、通过引擎的方法控制播放。这样音频不依附于任何路由:离开阅读页、
|
||||
// 换到别的书、甚至回到影视首页,播放都继续,由 Media Session 提供锁屏/通知栏控制。
|
||||
export type ReaderAudioStatus = 'idle' | 'loading' | 'playing' | 'paused' | 'error'
|
||||
|
||||
export interface ReaderAudioState {
|
||||
/** 当前音轨所属书籍;空串表示没有加载任何音轨。 */
|
||||
bookId: string
|
||||
bookName: string
|
||||
cover: string
|
||||
chapterIndex: number
|
||||
chapterTitle: string
|
||||
chapters: ReaderChapter[]
|
||||
/** 当前音轨地址(已签名,可直接交给 <audio> 或 hls.js)。 */
|
||||
track: string
|
||||
/** 该音轨由服务端转码,首次播放需要等待转码完成。 */
|
||||
transcoding: boolean
|
||||
openCredits: number
|
||||
closeCredits: number
|
||||
hasNext: boolean
|
||||
status: ReaderAudioStatus
|
||||
/** 加载/播放失败的可见原因(空串表示无错误)。 */
|
||||
error: string
|
||||
/** 当前播放位置(秒)。 */
|
||||
cur: number
|
||||
/** 总时长(秒),元数据未就绪时为 0。 */
|
||||
dur: number
|
||||
/** 播放倍速,与 readerSettings 的 audioSpeed 保持一致。 */
|
||||
speed: number
|
||||
timerActive: boolean
|
||||
/** 定时剩余秒数,0 表示未开启。 */
|
||||
timerLeft: number
|
||||
timerPreset: number
|
||||
}
|
||||
|
||||
const initialState: ReaderAudioState = {
|
||||
bookId: '',
|
||||
bookName: '',
|
||||
cover: '',
|
||||
chapterIndex: 0,
|
||||
chapterTitle: '',
|
||||
chapters: [],
|
||||
track: '',
|
||||
transcoding: false,
|
||||
openCredits: 0,
|
||||
closeCredits: 0,
|
||||
hasNext: false,
|
||||
status: 'idle',
|
||||
error: '',
|
||||
cur: 0,
|
||||
dur: 0,
|
||||
speed: 1,
|
||||
timerActive: false,
|
||||
timerLeft: 0,
|
||||
timerPreset: 0,
|
||||
}
|
||||
|
||||
export const useReaderAudioStore = create<ReaderAudioState>(() => ({ ...initialState }))
|
||||
|
||||
/** 引擎专用:整块重置回空闲态(停止播放时调用)。 */
|
||||
export function resetReaderAudio(): void {
|
||||
useReaderAudioStore.setState({ ...initialState })
|
||||
}
|
||||
@@ -45,6 +45,30 @@ export type ReaderShelfSort = 'recent' | 'update' | 'name' | 'author' | 'mixed'
|
||||
/** 书架网格列数:0 表示按屏幕自适应(legado 是固定 2–6 列)。 */
|
||||
export const SHELF_GRID_COLUMNS = [0, 2, 3, 4, 5, 6] as const
|
||||
|
||||
/**
|
||||
* 漫画图片的显示尺寸档位(上下滚动模式用)。
|
||||
*
|
||||
* legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),
|
||||
* 桌面端看不到也放不大,所以补上这一组档位。屏幕越宽越该放大,属于设备级偏好,
|
||||
* 和 comicDoublePage 一样只存本机、不参与账号同步。
|
||||
*
|
||||
* - `default` 现状:正文列封顶 900px 居中,图片铺满该列
|
||||
* - `width` 适应宽度:正文列不再封顶,图片铺满窗口宽度
|
||||
* - `height` 适应高度:每页高度贴合一屏(一屏一页),宽度按原始比例
|
||||
* - `long` 适应长边:整页完整可见,宽高都不超出视口
|
||||
* - `original` 原始像素 1:1:既不放大也不缩小,超出部分横向滚动
|
||||
*/
|
||||
export type ComicImageFit = 'default' | 'width' | 'height' | 'long' | 'original'
|
||||
|
||||
/** 漫画图片尺寸档位的展示顺序与标签(界面面板按此渲染)。 */
|
||||
export const COMIC_IMAGE_FITS: { id: ComicImageFit; label: string }[] = [
|
||||
{ id: 'default', label: '默认' },
|
||||
{ id: 'width', label: '适应宽度' },
|
||||
{ id: 'height', label: '适应高度' },
|
||||
{ id: 'long', label: '适应长边' },
|
||||
{ id: 'original', label: '原图' },
|
||||
]
|
||||
|
||||
interface ReaderSettingsState {
|
||||
// 首页模式切换(影视 / 阅读)
|
||||
homeMode: ReaderHomeMode
|
||||
@@ -77,6 +101,23 @@ interface ReaderSettingsState {
|
||||
pageMode: ReaderPageMode
|
||||
setPageMode: (mode: ReaderPageMode) => void
|
||||
|
||||
/** 漫画左右「双页铺开」偏好(legado 无对应项,是 Web 端桌面化补充)。 */
|
||||
comicDoublePage: boolean
|
||||
setComicDoublePage: (on: boolean) => void
|
||||
|
||||
/** 漫画图片显示尺寸档位(设备级偏好,见 ComicImageFit)。 */
|
||||
comicImageFit: ComicImageFit
|
||||
setComicImageFit: (fit: ComicImageFit) => void
|
||||
|
||||
// ── 搜索范围(对应 legado 搜索页的 SearchScopeDialog / AppConfig.searchScope)──
|
||||
/**
|
||||
* 已选书源 ID;空数组表示「全部启用书源」(默认)。只影响书籍搜索的并发范围。
|
||||
* 和 comicDoublePage 一样是设备级偏好:刻意不参与账号同步(legado 也把
|
||||
* searchScope 存在本机 AppConfig),换设备重新默认全选即可。
|
||||
*/
|
||||
searchScopeIds: string[]
|
||||
setSearchScopeIds: (ids: string[]) => void
|
||||
|
||||
fontSize: number
|
||||
setFontSize: (size: number) => void
|
||||
|
||||
@@ -136,6 +177,22 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
|
||||
pageMode: 'page',
|
||||
setPageMode: (pageMode) => set({ pageMode }),
|
||||
|
||||
// 默认开启:桌面端(窗口够宽)漫画自动两页铺开,窄屏(手机/窗口拉窄)
|
||||
// 由渲染层按窗口宽度自动退回单页,所以这里开着不会影响手机阅读。
|
||||
// 刻意不做跨设备同步:双页是否合适取决于屏幕宽度,属于设备级偏好,
|
||||
// 和 homeMode 同理(见 utils/readerSettingsSync.ts 的载荷字段)。
|
||||
comicDoublePage: true,
|
||||
setComicDoublePage: (comicDoublePage) => set({ comicDoublePage }),
|
||||
|
||||
// 默认保持老样子(正文列 900px 居中):放大到铺满屏幕是「想要更大」时才做的事,
|
||||
// 不该在升级后突然改变所有人已经习惯的宽度。想放大点「适应宽度」,想 1:1 看细节
|
||||
// 点「原图」。
|
||||
comicImageFit: 'default',
|
||||
setComicImageFit: (comicImageFit) => set({ comicImageFit }),
|
||||
|
||||
searchScopeIds: [],
|
||||
setSearchScopeIds: (ids) => set({ searchScopeIds: [...new Set(ids)] }),
|
||||
|
||||
fontSize: 20,
|
||||
setFontSize: (fontSize) => set({ fontSize: Math.min(32, Math.max(14, fontSize)) }),
|
||||
|
||||
@@ -162,7 +219,9 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
|
||||
* 与账号同步的阅读器偏好(对应后端 GET/PUT /reader/profile 的载荷)。
|
||||
*
|
||||
* 这些设置原先是设备级的(只存 localStorage);现在按用户落库,换设备也能保持一致。
|
||||
* 首页的「影视 / 阅读」模式(homeMode)属于设备偏好,故意不参与同步。
|
||||
* 首页的「影视 / 阅读」模式(homeMode)、漫画双页(comicDoublePage)与漫画图片尺寸
|
||||
* (comicImageFit)属于设备偏好,故意不参与同步——前者是入口选择,后两者是否合适
|
||||
* 取决于屏幕宽度和分辨率。
|
||||
*/
|
||||
export interface ReaderSettingsProfile {
|
||||
theme_id: string
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { comicImageSizing, isSelfSizedComicFit } from './comicImageFit.ts'
|
||||
import type { ComicImageFit } from '../stores/readerSettings'
|
||||
|
||||
// 漫画图片显示尺寸档位的纯逻辑回归测试(对齐 utils/coverUrl.test.ts 的写法)。
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`comicImageFit: ${name}`)
|
||||
}
|
||||
|
||||
// ── 是否由图片自身约束尺寸 ──
|
||||
const ALL_FITS: ComicImageFit[] = ['default', 'width', 'height', 'long', 'original']
|
||||
check('default 不是自尺寸档位', !isSelfSizedComicFit('default'))
|
||||
check('width 不是自尺寸档位', !isSelfSizedComicFit('width'))
|
||||
check('height 是自尺寸档位', isSelfSizedComicFit('height'))
|
||||
check('long 是自尺寸档位', isSelfSizedComicFit('long'))
|
||||
check('original 是自尺寸档位', isSelfSizedComicFit('original'))
|
||||
check(
|
||||
'自尺寸档位与列宽档位互斥且覆盖全部取值',
|
||||
ALL_FITS.every((fit) => isSelfSizedComicFit(fit) === (fit === 'height' || fit === 'long' || fit === 'original')),
|
||||
)
|
||||
|
||||
// ── default / width:宽度交给外层列宽,图片只负责铺满 ──
|
||||
for (const fit of ['default', 'width'] as ComicImageFit[]) {
|
||||
const loaded = comicImageSizing(fit, 800, true)
|
||||
check(`${fit} 加载完成时不留占位高度`, loaded.style.minHeight === undefined)
|
||||
check(`${fit} 铺满外层列宽`, loaded.className === 'block w-full')
|
||||
check(`${fit} 不自己定高`, loaded.style.height === undefined && loaded.style.maxHeight === undefined)
|
||||
check(`${fit} 不改 maxWidth`, loaded.style.maxWidth === undefined)
|
||||
check(`${fit} 加载中给占位高度`, comicImageSizing(fit, 800, false).style.minHeight === '10rem')
|
||||
}
|
||||
|
||||
// ── height:一屏一页 ──
|
||||
check('height 用滚动容器的实测高度', comicImageSizing('height', 592, true).style.height === '592px')
|
||||
check('height 宽度按原始比例', comicImageSizing('height', 592, true).style.width === 'auto')
|
||||
check('height 防超宽页横向溢出', comicImageSizing('height', 592, true).style.maxWidth === '100vw')
|
||||
check('height 不额外设 maxHeight', comicImageSizing('height', 592, true).style.maxHeight === undefined)
|
||||
check('height 类名不带 w-full(w-full 会顶掉高度约束)', comicImageSizing('height', 592, true).className === 'block')
|
||||
|
||||
// ── long:整页完整可见 ──
|
||||
check('long 用容器高度封顶', comicImageSizing('long', 592, true).style.maxHeight === '592px')
|
||||
check('long 不写死高度', comicImageSizing('long', 592, true).style.height === undefined)
|
||||
check('long 同时约束宽度', comicImageSizing('long', 592, true).style.maxWidth === '100vw')
|
||||
check('long 加载中给占位高度', comicImageSizing('long', 592, false).style.minHeight === '10rem')
|
||||
|
||||
// ── original:原始像素 1:1 ──
|
||||
check('original 不限制宽度', comicImageSizing('original', 592, true).style.maxWidth === 'none')
|
||||
check('original 不限制高度', comicImageSizing('original', 592, true).style.maxHeight === undefined)
|
||||
check('original 按原始像素渲染', comicImageSizing('original', 592, true).style.width === 'auto')
|
||||
|
||||
// ── 视口高度还没量出来时退回视口单位,首帧不塌 ──
|
||||
for (const fit of ['height', 'long'] as ComicImageFit[]) {
|
||||
check(`${fit} 未量到高度时退回 100dvh`, comicImageSizing(fit, 0, true).style[fit === 'height' ? 'height' : 'maxHeight'] === '100dvh')
|
||||
check(`${fit} 高度为负同样退回 100dvh`, comicImageSizing(fit, -10, true).style[fit === 'height' ? 'height' : 'maxHeight'] === '100dvh')
|
||||
}
|
||||
check('default 与视口高度无关', comicImageSizing('default', 0, true).style.height === undefined)
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import type { ComicImageFit } from '../stores/readerSettings'
|
||||
|
||||
// 漫画图片的显示尺寸:把「档位」翻译成 <img> 的 class/style(纯逻辑,便于单测)。
|
||||
//
|
||||
// 只有上下滚动模式用得到:翻页模式是整页缩放进视口(object-contain),本来就没有
|
||||
// 「太大/太小」的问题,一个档位反而会和双页铺开的排版打架。
|
||||
//
|
||||
// 两种控制方式:
|
||||
// - default / width 由外层列宽决定(列封顶 900px 或铺满窗口),图片本身 w-full;
|
||||
// - height / long / original 由图片自身约束,所以外层必须放开列宽上限
|
||||
// (见 ReaderViewPage 的 comicFullWidth),否则 900px 的列会把它们再压回去。
|
||||
|
||||
/** 图片还没加载出来时的占位高度,避免懒加载期间整屏塌成 0 高。 */
|
||||
const PLACEHOLDER_MIN_HEIGHT = '10rem'
|
||||
|
||||
export interface ComicImageSizing {
|
||||
className: string
|
||||
style: CSSProperties
|
||||
}
|
||||
|
||||
/** 该档位是否由 <img> 自身约束尺寸(决定外层列宽要不要放开)。 */
|
||||
export function isSelfSizedComicFit(fit: ComicImageFit): boolean {
|
||||
return fit === 'height' || fit === 'long' || fit === 'original'
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动模式下漫画图片的尺寸样式。
|
||||
*
|
||||
* @param fit 显示尺寸档位。
|
||||
* @param viewportHeight 滚动容器的高度(px)。height/long 按它换算;还没量出来
|
||||
* (0)时退回 `100dvh`,首帧不会因为 0 而塌掉。
|
||||
* @param loaded 图片是否已加载完成,未完成时给个占位高度。
|
||||
*/
|
||||
export function comicImageSizing(
|
||||
fit: ComicImageFit,
|
||||
viewportHeight: number,
|
||||
loaded: boolean,
|
||||
): ComicImageSizing {
|
||||
const vh = viewportHeight > 0 ? `${viewportHeight}px` : '100dvh'
|
||||
// height 档位自带高度,占位高度没有意义也不冲突,统一先铺再被档位覆盖。
|
||||
const placeholder: CSSProperties = loaded ? {} : { minHeight: PLACEHOLDER_MIN_HEIGHT }
|
||||
|
||||
switch (fit) {
|
||||
case 'height':
|
||||
// 一屏一页:高度贴合视口,宽度按原始比例(maxWidth 防止超宽页横向溢出)
|
||||
return { className: 'block', style: { ...placeholder, height: vh, width: 'auto', maxWidth: '100vw' } }
|
||||
case 'long':
|
||||
// 整页完整可见:长边贴合视口,宽高都不超出
|
||||
return { className: 'block', style: { ...placeholder, maxHeight: vh, maxWidth: '100vw', width: 'auto' } }
|
||||
case 'original':
|
||||
// 原始像素 1:1:不放大也不缩小;比窗口宽时由滚动容器横向滚动
|
||||
return { className: 'block', style: { ...placeholder, width: 'auto', maxWidth: 'none' } }
|
||||
default:
|
||||
// default / width:宽度交给外层列(900px 封顶或铺满窗口),这里只管铺满
|
||||
return { className: 'block w-full', style: placeholder }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// 漫画「双页铺开」的纯逻辑与图片尺寸探测。
|
||||
//
|
||||
// 桌面端一屏能并排放下两页,漫画按「左右两页」成对观看才接近纸质阅读体验
|
||||
// (尤其日漫的跨页)。麻烦在于章节里常混着「本来就横跨两页」的宽图(跨页扉页、
|
||||
// 彩页):这类图必须独占一屏,否则会被硬塞进一对里,从它开始整章都错位。
|
||||
//
|
||||
// 所以分组规则与 Tachiyomi 的 dual page 一致:宽度明显大于高度的图独占一组,
|
||||
// 其余连续的竖版页按顺序两两成对。判断需要原始宽高,而列表接口只给 URL,
|
||||
// 这里就自己探测并缓存(<img> 渲染时也会顺带回填,能省掉一次请求)。
|
||||
|
||||
/** 图片原始像素尺寸。 */
|
||||
export interface ImageSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** 宽高比达到这个值即视为「跨页宽图」。普通页约 0.6~0.7,跨页图约 1.3~1.5。 */
|
||||
const WIDE_RATIO = 1.1
|
||||
|
||||
/** 已探测到的尺寸,按 URL 缓存(null = 探测失败,当竖版处理),跨章节复用。 */
|
||||
const sizeCache = new Map<string, ImageSize | null>()
|
||||
/** 正在探测的图片,避免同一 URL 并发发起多次请求。 */
|
||||
const inflight = new Map<string, Promise<ImageSize | null>>()
|
||||
|
||||
/** 渲染中的 <img> onLoad 顺带回填尺寸,省掉一次探测请求。 */
|
||||
export function rememberImageSize(src: string, width: number, height: number): void {
|
||||
if (!src || !width || !height) return
|
||||
sizeCache.set(src, { width, height })
|
||||
}
|
||||
|
||||
/** 该 URL 是否已经探测过(成功的记尺寸,失败的记 null)。 */
|
||||
export function hasImageSize(src: string): boolean {
|
||||
return sizeCache.has(src)
|
||||
}
|
||||
|
||||
/** 探测一张图片的原始尺寸;失败(404 / 被拦 / 解码失败)返回 null。 */
|
||||
export function loadImageSize(src: string): Promise<ImageSize | null> {
|
||||
const cached = sizeCache.get(src)
|
||||
if (cached !== undefined) return Promise.resolve(cached)
|
||||
const running = inflight.get(src)
|
||||
if (running) return running
|
||||
const task = new Promise<ImageSize | null>((resolve) => {
|
||||
const img = new Image()
|
||||
const settle = (size: ImageSize | null) => {
|
||||
sizeCache.set(src, size)
|
||||
inflight.delete(src)
|
||||
resolve(size)
|
||||
}
|
||||
img.onload = () =>
|
||||
settle(
|
||||
img.naturalWidth > 0 && img.naturalHeight > 0
|
||||
? { width: img.naturalWidth, height: img.naturalHeight }
|
||||
: null,
|
||||
)
|
||||
img.onerror = () => settle(null)
|
||||
img.src = src
|
||||
})
|
||||
inflight.set(src, task)
|
||||
return task
|
||||
}
|
||||
|
||||
/** 该图是否横跨两页(宽明显大于高)。尺寸未知时按竖版处理,宁可先配对。 */
|
||||
export function isWideImage(src: string): boolean {
|
||||
const size = sizeCache.get(src)
|
||||
if (!size) return false
|
||||
return size.width / size.height >= WIDE_RATIO
|
||||
}
|
||||
|
||||
/** 一「屏」要显示的图片序号:一张=独占的宽图/单页,两张=左右并排的一对。 */
|
||||
export type ComicSpread = number[]
|
||||
|
||||
/**
|
||||
* 把整章图片按双页铺开分组:连续的竖版页两两成对,宽图独占一组并打断前后配对。
|
||||
* 尺寸还没探测出来的图先按竖版参与配对,探测完成后再重新分组(见 useComicSpreads)。
|
||||
*/
|
||||
export function buildSpreads(srcs: string[]): ComicSpread[] {
|
||||
const spreads: ComicSpread[] = []
|
||||
let i = 0
|
||||
while (i < srcs.length) {
|
||||
if (isWideImage(srcs[i])) {
|
||||
spreads.push([i])
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
// 收集一段连续的竖版页,两两成对;落单的最后一张自己一屏(常见于章尾)
|
||||
const run: number[] = []
|
||||
while (i < srcs.length && !isWideImage(srcs[i])) {
|
||||
run.push(i)
|
||||
i += 1
|
||||
}
|
||||
for (let k = 0; k < run.length; k += 2) spreads.push(run.slice(k, k + 2))
|
||||
}
|
||||
return spreads
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片序号 → 所在屏序号。进度是按图片序号记的,翻页要换算成「当前在第几屏」,
|
||||
* 并且不管展开/收起双页都稳定(只由图片序号推出来,不额外存一屏序号)。
|
||||
*/
|
||||
export function buildSpreadIndexOf(spreads: ComicSpread[], total: number): number[] {
|
||||
const map = new Array<number>(Math.max(0, total)).fill(0)
|
||||
spreads.forEach((spread, si) => {
|
||||
for (const idx of spread) {
|
||||
if (idx >= 0 && idx < map.length) map[idx] = si
|
||||
}
|
||||
})
|
||||
return map
|
||||
}
|
||||
Reference in New Issue
Block a user