mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8b02cb3a7 | |||
| 60cfad47e5 | |||
| 47c8bfdd6c | |||
| ce3baee8f1 |
@@ -33,13 +33,13 @@
|
||||
- 紧凑列表:48×64 小封面 + 书名 +「作者 · 读到」,一屏放更多书(对应 legado `layout_list_compact`)。
|
||||
- 排序六档,对齐 legado `AppConfig.getBookSortByGroupId`:最近阅读(dur_chapter_time,默认)/ 最近更新(latest_chapter_time)/ 综合 / 按书名 / 按作者 / 手动顺序(order)。顶栏有快捷排序下拉,弹窗里也可选。
|
||||
- 显示项开关:未读章数徽标、更新时间。
|
||||
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。
|
||||
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。
|
||||
- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载。
|
||||
|
||||
**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)**
|
||||
- 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。
|
||||
- 接口:`GET /reader/book-groups`(没有分组返回空数组)、`PUT /reader/book-groups`(整份替换)。服务端落库前做两条需要「知道书是否存在」的收敛:只保留该用户书架上的书(书移出书架后分组里不留死 ID)、一本书只归一个组(越靠前的分组优先)。空分组保留,方便先建组再放书。
|
||||
- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。
|
||||
- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现,且固定在分组栏最后,避免挤开用户自己拖拽排好的分组顺序);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。
|
||||
- 前端:`utils/readerBookGroups.ts`(纯逻辑)+ `hooks/useBookGroups.ts`(乐观更新、串行提交、失败回滚,接口不可用时退回 localStorage,照搬 `useLibraryTags`)+ `components/BookGroupBar.tsx`(分组栏)+ `ManageBookGroupsDialogView.tsx`(新建/重命名/删除/拖拽排序)+ `BookGroupPickerDialog.tsx`(移到分组)。
|
||||
|
||||
**阅读器偏好按账号同步(跨设备)**
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:书籍类型兜底的回归测试。
|
||||
//
|
||||
// 背景:书籍详情页的「加入书架/开始阅读」曾把 origin_type 写死成 0(文本),
|
||||
// 于是听书源的音频书以文本类型落库。正文类型按「规则 JS 声明 > 书架类型 >
|
||||
// 书源类型」取值,书架里的 0 压过书源的 1,播放直链就被当成正文排出来,
|
||||
// 整个阅读页是一屏 URL(用户截图里的现象)。
|
||||
//
|
||||
// 兜底规则:书架类型还停在默认的「文本」时用书源类型;规则 JS 显式声明的
|
||||
// 类型仍然最优先,避免改坏「文本型聚合源提供听书内容」那类源。
|
||||
|
||||
// audioTestSourceJSON 一个最小听书源:正文规则给 JSON 里的播放直链。
|
||||
func audioTestSourceJSON(t *testing.T, base string) string {
|
||||
t.Helper()
|
||||
return `{
|
||||
"bookSourceName": "听书测试源",
|
||||
"bookSourceType": 1,
|
||||
"bookSourceUrl": "` + base + `",
|
||||
"ruleContent": {"content": "$.data.url"}
|
||||
}`
|
||||
}
|
||||
|
||||
// TestAudioBookTypeFallbackBySource 书架类型是默认的文本时按书源类型(音频)渲染,
|
||||
// 并把纠正后的类型写回书架记录。
|
||||
func TestAudioBookTypeFallbackBySource(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`{"data":{"url":"https://cdn.example.com/a.m4a"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := prepareLoginSource(t, svc, audioTestSourceJSON(t, srv.URL))
|
||||
|
||||
// 复刻详情页写死的 origin_type=0
|
||||
book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{
|
||||
SourceID: sourceID,
|
||||
Origin: srv.URL,
|
||||
OriginName: "听书测试源",
|
||||
OriginType: 0,
|
||||
BookURL: srv.URL + "/book/1",
|
||||
}, "测试听书", "某作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
|
||||
}); err != nil {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "audio" {
|
||||
t.Fatalf("正文类型 = %q(期望 audio:书架类型是默认文本时应当按书源类型兜底),正文 = %.120q",
|
||||
out.Type, out.Content)
|
||||
}
|
||||
if len(out.Tracks) != 1 {
|
||||
t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks))
|
||||
}
|
||||
// 音轨要落到签名代理上(浏览器播放带不上书源的防盗链头)
|
||||
if !strings.HasPrefix(out.Tracks[0], "/api/reader/media?") {
|
||||
t.Fatalf("音轨 = %q,期望签名代理地址", out.Tracks[0])
|
||||
}
|
||||
raw, err := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s"))
|
||||
if err != nil {
|
||||
t.Fatalf("代理地址校验失败: %v", err)
|
||||
}
|
||||
if raw != "https://cdn.example.com/a.m4a" {
|
||||
t.Fatalf("代理还原地址 = %q", raw)
|
||||
}
|
||||
|
||||
// 纠正后的类型要写回书架:否则每读一章都要再兜底一次,注入规则 JS 的
|
||||
// book.type 也一直是错的(书源 JS 会读它分支)。
|
||||
reloaded, err := svc.GetBook(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("重读书籍失败: %v", err)
|
||||
}
|
||||
if reloaded.Type != 1 {
|
||||
t.Fatalf("书架记录类型 = %d,期望 1(音频)", reloaded.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeclaredTextTypeWinsOverAudioSource 规则 JS 显式声明类型时不套用书源类型兜底:
|
||||
// 文本型聚合源(bookSourceType=0,但听书内容靠 JS 声明)反过来也不会被覆盖成文本。
|
||||
func TestDeclaredTextTypeWinsOverAudioSource(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`{"data":{"url":"https://cdn.example.com/a.m4a"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
// 音频书源,但正文规则用 JS 把 book.type 声明成文本(legado 的 BookType 位掩码 8)
|
||||
src := `{
|
||||
"bookSourceName": "声明文本的音频源",
|
||||
"bookSourceType": 1,
|
||||
"bookSourceUrl": "` + srv.URL + `",
|
||||
"ruleContent": {"content": "@js:book.type = 8; result"}
|
||||
}`
|
||||
sourceID := prepareLoginSource(t, svc, src)
|
||||
|
||||
book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{
|
||||
SourceID: sourceID, Origin: srv.URL, OriginName: "声明文本的音频源",
|
||||
OriginType: 0, BookURL: srv.URL + "/book/1",
|
||||
}, "声明文本", "某作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
|
||||
}); err != nil {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "text" {
|
||||
t.Fatalf("正文类型 = %q,期望 text(规则 JS 声明的类型优先于书源类型)", out.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// trackQueryValue 取出代理地址里的查询参数值(测试用)。
|
||||
func trackQueryValue(t *testing.T, raw, key string) string {
|
||||
t.Helper()
|
||||
idx := strings.Index(raw, key+"=")
|
||||
if idx < 0 {
|
||||
t.Fatalf("地址 %q 里没有参数 %s", raw, key)
|
||||
}
|
||||
rest := raw[idx+len(key)+1:]
|
||||
if end := strings.Index(rest, "&"); end >= 0 {
|
||||
rest = rest[:end]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
@@ -1139,6 +1139,21 @@ func normalizeBookType(t int) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// contentTypeCode 把 ChapterContent.Type 换算回书架记录的类型码
|
||||
// (0 文本 / 1 音频 / 2 图片,与 model.ReaderBook.Type 同一套约定)。
|
||||
// 第二个返回值为 false 表示这个渲染类型没有对应的书籍类型(如视频)。
|
||||
func contentTypeCode(t string) (int, bool) {
|
||||
switch t {
|
||||
case "text":
|
||||
return 0, true
|
||||
case "audio":
|
||||
return 1, true
|
||||
case "image":
|
||||
return 2, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// GetToc 抓取目录。返回值中的 declaredType 是书源在规则 JS 里声明的书籍类型
|
||||
// (-1 表示未声明),书源用它在目录阶段把听书/漫画/短剧源标成对应类型。
|
||||
func (s *ReaderService) GetToc(ctx context.Context, userID, sourceID, sourceURL, bookURL, tocURL string) ([]TocChapter, error) {
|
||||
@@ -1655,7 +1670,14 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
|
||||
if declaredType >= 0 {
|
||||
effective = declaredType
|
||||
}
|
||||
if effective < 0 {
|
||||
// 书架类型还停在默认的「文本」时,用书源声明的类型兜底。
|
||||
//
|
||||
// 书籍详情页的「加入书架/开始阅读」曾经把 origin_type 写死成 0(见 web
|
||||
// ReaderBookPage),听书/漫画书于是以文本类型落库;而书架类型优先级高于
|
||||
// 书源类型,正文就被当文本渲染——听书源的播放直链会排满整个阅读页。
|
||||
// 规则 JS 里显式声明的类型(declaredType)仍然最优先,所以「文本型聚合源
|
||||
// 提供听书/漫画内容」这类靠 JS 声明类型的情形不受影响。
|
||||
if declaredType < 0 && effective <= 0 {
|
||||
effective = src.Type
|
||||
}
|
||||
switch effective {
|
||||
@@ -2052,11 +2074,24 @@ func (s *ReaderService) WarmUpBookChapters(ctx context.Context, userID string, b
|
||||
|
||||
// TocRefreshResult 「更新目录」的结果汇总(对应 legado 更新目录后的提示)。
|
||||
type TocRefreshResult struct {
|
||||
Total int `json:"total"` // 参与刷新的网络书籍数
|
||||
Updated int `json:"updated"` // 章数变多的书(有新章节)
|
||||
Failed int `json:"failed"` // 抓取或写入失败的书
|
||||
Total int `json:"total"` // 参与刷新的网络书籍数
|
||||
Updated int `json:"updated"` // 章数变多的书(有新章节)
|
||||
Unchanged int `json:"unchanged"` // 抓取成功但没有新章节的书(已是最新)
|
||||
Failed int `json:"failed"` // 抓取或写入失败的书
|
||||
}
|
||||
|
||||
// tocRefreshStatus 单本书目录刷新的结果。
|
||||
type tocRefreshStatus int
|
||||
|
||||
const (
|
||||
// tocRefreshUnchanged 抓取并覆盖成功,只是章数没变多(完结书、站点暂无更新)。
|
||||
tocRefreshUnchanged tocRefreshStatus = iota
|
||||
// tocRefreshUpdated 抓取成功且章数变多,有新章节。
|
||||
tocRefreshUpdated
|
||||
// tocRefreshFailed 抓不到目录、抓到空目录或写不进缓存。
|
||||
tocRefreshFailed
|
||||
)
|
||||
|
||||
// refreshTocConcurrency 「更新目录」的并发度。书源站点多有限流,不宜过大。
|
||||
const refreshTocConcurrency = 4
|
||||
|
||||
@@ -2065,6 +2100,8 @@ const refreshTocConcurrency = 4
|
||||
// 逐本重新抓目录、覆盖章节缓存;末章变化时由 GetToc → applyTocMeta 刷新
|
||||
// latest_chapter_time。本地书籍与没有书源信息的书籍跳过。
|
||||
// 并发受限,单本失败只计数、不中断整体;等待全部结束后返回汇总。
|
||||
// 「抓到但没新章节」与「抓失败」分开计数:前者是正常结果(完结书天天检查都没有新章节),
|
||||
// 混在一起会让人误以为书源坏了。
|
||||
func (s *ReaderService) RefreshBooksToc(ctx context.Context, userID string) (*TocRefreshResult, error) {
|
||||
books, err := s.repo.ListBooks(ctx, userID)
|
||||
if err != nil {
|
||||
@@ -2096,43 +2133,67 @@ func (s *ReaderService) RefreshBooksToc(ctx context.Context, userID string) (*To
|
||||
defer func() { <-sem }()
|
||||
bookCtx, cancel := context.WithTimeout(ctx, perSourceTimeout)
|
||||
defer cancel()
|
||||
if s.refreshBookToc(bookCtx, userID, b) {
|
||||
mu.Lock()
|
||||
res.Updated++
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
status := s.refreshBookToc(bookCtx, userID, b)
|
||||
mu.Lock()
|
||||
res.Failed++
|
||||
mu.Unlock()
|
||||
defer mu.Unlock()
|
||||
switch status {
|
||||
case tocRefreshUpdated:
|
||||
res.Updated++
|
||||
case tocRefreshUnchanged:
|
||||
res.Unchanged++
|
||||
default:
|
||||
res.Failed++
|
||||
}
|
||||
}(b)
|
||||
}
|
||||
wg.Wait()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// refreshBookToc 刷新单本书的目录,返回是否检测到新章节。
|
||||
func (s *ReaderService) refreshBookToc(ctx context.Context, userID string, book model.ReaderBook) bool {
|
||||
// refreshBookToc 刷新单本书的目录,返回本次刷新的结果。
|
||||
func (s *ReaderService) refreshBookToc(ctx context.Context, userID string, book model.ReaderBook) tocRefreshStatus {
|
||||
before, _ := s.repo.CountChaptersByBook(ctx, []string{book.ID})
|
||||
chapters, err := s.GetToc(ctx, userID, "", book.Origin, book.BookURL, book.TocURL)
|
||||
if err != nil || len(chapters) == 0 {
|
||||
if err != nil && s.log != nil {
|
||||
s.log.Debug("reader: 更新目录失败", zap.String("book", book.ID), zap.Error(err))
|
||||
}
|
||||
return false
|
||||
if err != nil {
|
||||
s.logTocRefreshFailure("reader: 更新目录失败", book, err)
|
||||
return tocRefreshFailed
|
||||
}
|
||||
// 书源规则不报错但一章都没解析出来,多是站点返回了风控/报错响应体(规则解析成空列表)。
|
||||
// 这种静默失败要当失败处理并留下日志,否则书架上只会看到「已是最新」。
|
||||
if len(chapters) == 0 {
|
||||
s.logTocRefreshFailure("reader: 更新目录为空", book, nil)
|
||||
return tocRefreshFailed
|
||||
}
|
||||
inputs := make([]ChapterInput, 0, len(chapters))
|
||||
for _, ch := range chapters {
|
||||
inputs = append(inputs, ChapterInput{Index: ch.Index, Title: ch.Title, URL: ch.URL, IsVolume: ch.IsVolume})
|
||||
}
|
||||
if err := s.SaveChapters(ctx, book.ID, inputs); err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("reader: 更新目录写入失败", zap.String("book", book.ID), zap.Error(err))
|
||||
}
|
||||
return false
|
||||
s.logTocRefreshFailure("reader: 更新目录写入失败", book, err)
|
||||
return tocRefreshFailed
|
||||
}
|
||||
beforeCount := before[book.ID]
|
||||
return beforeCount > 0 && len(chapters) > beforeCount
|
||||
// 首次抓目录(书架还没有缓存)没有可比基准,按「已是最新」计,不虚报新章节。
|
||||
if beforeCount := before[book.ID]; beforeCount > 0 && len(chapters) > beforeCount {
|
||||
return tocRefreshUpdated
|
||||
}
|
||||
return tocRefreshUnchanged
|
||||
}
|
||||
|
||||
// logTocRefreshFailure 记录目录刷新失败。用 Warn 而不是 Debug:这类失败基本都来自
|
||||
// 书源站点(风控、改版、限流),排查时只能靠日志,info 级别下 Debug 是看不到的。
|
||||
func (s *ReaderService) logTocRefreshFailure(msg string, book model.ReaderBook, err error) {
|
||||
if s.log == nil {
|
||||
return
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("book", book.ID),
|
||||
zap.String("name", book.Name),
|
||||
zap.String("origin", book.OriginName),
|
||||
}
|
||||
if err != nil {
|
||||
fields = append(fields, zap.Error(err))
|
||||
}
|
||||
s.log.Warn(msg, fields...)
|
||||
}
|
||||
|
||||
// ListReplaceRules 用户替换规则列表。
|
||||
@@ -2257,6 +2318,14 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
|
||||
if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil {
|
||||
s.log.Warn("reader: 写回书籍类型失败", zap.String("book", book.ID), zap.Error(err))
|
||||
}
|
||||
} else if code, ok := contentTypeCode(out.Type); ok && code != 0 && code != book.Type {
|
||||
// 书源类型兜底纠正出来的音频/漫画类型也落库:这些书被详情页写死的
|
||||
// origin_type=0 记成了文本,不写回的话每读一章都要再纠正一次,
|
||||
// 注入规则 JS 的 book.type 也一直是错的。只向上纠正,不覆盖成文本。
|
||||
book.Type = code
|
||||
if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil {
|
||||
s.log.Warn("reader: 修正书籍类型失败", zap.String("book", book.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
if out.Type == "text" {
|
||||
out.Content = s.applyUserReplaceRules(ctx, userID, book.Name, out.Content)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 本文件:「更新目录」的结果分档回归测试。
|
||||
//
|
||||
// 背景:书架提示曾经把「抓到了目录但没有新章节」也算成失败。完结书每次检查都没有新章节,
|
||||
// 于是书架天天报「N 本失败」,看起来像书源坏了。现在必须区分三档:
|
||||
// 抓到新章节 / 抓到但没变(已是最新)/ 抓取或写入失败。
|
||||
|
||||
// refreshTocForTest 跑一次「更新目录」并返回汇总。
|
||||
func refreshTocForTest(t *testing.T, svc *ReaderService, ctx context.Context) *TocRefreshResult {
|
||||
t.Helper()
|
||||
res, err := svc.RefreshBooksToc(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatalf("更新目录失败: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// newRefreshTocServer 建一个目录页内容可切换的测试源站点。
|
||||
func newRefreshTocServer(tocHandler *http.HandlerFunc) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/book/1/toc"):
|
||||
(*tocHandler)(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/book/"):
|
||||
_, _ = w.Write([]byte(e2eBookInfoHTML))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestRefreshBooksTocSeparatesUnchangedFromFailed(t *testing.T) {
|
||||
tocHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(e2eTocHTML))
|
||||
})
|
||||
srv := newRefreshTocServer(&tocHandler)
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: srv.URL,
|
||||
OriginName: "测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
TocURL: srv.URL + "/book/1/toc.html",
|
||||
Name: "斗破苍穹",
|
||||
}
|
||||
if err := svc.repo.CreateBook(ctx, book); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
|
||||
// 1) 首次刷新:抓到 2 章并落库。书架原先没有目录缓存,没有可比基准,算「已是最新」。
|
||||
res := refreshTocForTest(t, svc, ctx)
|
||||
if res.Total != 1 || res.Updated != 0 || res.Unchanged != 1 || res.Failed != 0 {
|
||||
t.Fatalf("首次刷新 = %+v,期望 total 1 / updated 0 / unchanged 1 / failed 0", res)
|
||||
}
|
||||
chapters, err := svc.ListChapters(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("目录缓存 = %d 章,期望 2", len(chapters))
|
||||
}
|
||||
|
||||
// 2) 站点多出一章:这才算「有新章节」。
|
||||
tocHandler = func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(strings.Replace(
|
||||
e2eTocHTML, "</ul>", `<li><a href="/book/1/c3.html">第三章 药老</a></li></ul>`, 1,
|
||||
)))
|
||||
}
|
||||
res = refreshTocForTest(t, svc, ctx)
|
||||
if res.Updated != 1 || res.Unchanged != 0 || res.Failed != 0 {
|
||||
t.Fatalf("出现新章节时 = %+v,期望 updated 1 / unchanged 0 / failed 0", res)
|
||||
}
|
||||
|
||||
// 3) 站点返回风控响应体(HTTP 200 但不是目录):规则解析成空列表且不报错,
|
||||
// 这种静默失败必须记失败,不能报成「已是最新」。
|
||||
tocHandler = func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":210,"message":"請到官網更新最新APP,請下載安裝正版之後等待1小時。"}`))
|
||||
}
|
||||
res = refreshTocForTest(t, svc, ctx)
|
||||
if res.Failed != 1 || res.Unchanged != 0 || res.Updated != 0 {
|
||||
t.Fatalf("风控响应体 = %+v,期望 updated 0 / unchanged 0 / failed 1", res)
|
||||
}
|
||||
// 抓失败不能动已有缓存,否则阅读器会连本来能看的章节都读不到。
|
||||
chapters, err = svc.ListChapters(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chapters) != 3 {
|
||||
t.Fatalf("抓失败后目录缓存 = %d 章,期望保持 3 章", len(chapters))
|
||||
}
|
||||
|
||||
// 4) 站点 404:抓取失败。
|
||||
tocHandler = func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }
|
||||
res = refreshTocForTest(t, svc, ctx)
|
||||
if res.Failed != 1 || res.Unchanged != 0 {
|
||||
t.Fatalf("站点 404 = %+v,期望 unchanged 0 / failed 1", res)
|
||||
}
|
||||
}
|
||||
|
||||
// 本地书籍与没有书源信息的书籍不参与刷新,汇总里也不该出现。
|
||||
func TestRefreshBooksTocSkipsLocalAndSourcelessBooks(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
local := &model.ReaderBook{UserID: "u1", Name: "本地书", LocalPath: "/tmp/a.txt", BookURL: "x", Origin: "y"}
|
||||
sourceless := &model.ReaderBook{UserID: "u1", Name: "无书源", BookURL: "", Origin: ""}
|
||||
for _, b := range []*model.ReaderBook{local, sourceless} {
|
||||
if err := svc.repo.CreateBook(ctx, b); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
res := refreshTocForTest(t, svc, ctx)
|
||||
if res.Total != 0 || res.Updated != 0 || res.Unchanged != 0 || res.Failed != 0 {
|
||||
t.Fatalf("跳过本地/无源书籍时 = %+v,期望全 0", res)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -317,11 +317,16 @@ export const readerAPI = {
|
||||
api.put<{ groups: BookGroup[] | null }>('/reader/book-groups', { groups }).then((r) => r.data.groups ?? []),
|
||||
/**
|
||||
* 更新目录(对应 legado 书架的「更新目录」):重抓书架里全部网络书籍的目录,
|
||||
* 覆盖章节缓存并刷新「最近更新」时间。返回本次刷新的汇总。
|
||||
* 覆盖章节缓存并刷新「最近更新」时间。返回本次刷新的汇总:
|
||||
* updated 有新章节 / unchanged 抓成功但没新章节 / failed 抓取或写入失败。
|
||||
*/
|
||||
refreshBooksToc: () =>
|
||||
api
|
||||
.post<{ total: number; updated: number; failed: number }>('/reader/shelf/refresh-toc', {}, longOpts)
|
||||
.post<{ total: number; updated: number; unchanged: number; failed: number }>(
|
||||
'/reader/shelf/refresh-toc',
|
||||
{},
|
||||
longOpts,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
/**
|
||||
* 换源:把书架里的书切到另一个书源。
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { BookGroupTab } from '../utils/readerBookGroups'
|
||||
|
||||
/**
|
||||
* 书架分组栏(对齐影视模块的 LibraryTagBar):
|
||||
* 固定「全部」+(有分组时)「未分组」+ 每个分组一个页签,选中后只展示该分组的书。
|
||||
* 固定「全部」+ 每个分组一个页签 +(有分组时)「未分组」殿后,选中后只展示该分组的书。
|
||||
*
|
||||
* 只是展示层过滤,不改动书架数据本身。这里只负责展示与选中:新建 / 重命名 / 删除 /
|
||||
* 拖拽排序都在「管理」菜单的「管理分组」弹窗里做,分组栏保持干净。
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export default function ReaderBookPage() {
|
||||
const [chapters, setChapters] = useState<ReaderTocChapter[] | null>(null)
|
||||
const [shelfBook, setShelfBook] = useState<ReaderBook | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 当前书源声明的类型(1 音频 / 2 图片),加入书架时写进来源记录
|
||||
const [sourceType, setSourceType] = useState(0)
|
||||
const [tocExpanded, setTocExpanded] = useState(false)
|
||||
const [introExpanded, setIntroExpanded] = useState(false)
|
||||
// 换源:候选源来自按书名重新搜索的结果
|
||||
@@ -58,6 +60,16 @@ export default function ReaderBookPage() {
|
||||
.catch(() => setChapters([]))
|
||||
}, [bookURL, sourceURL])
|
||||
|
||||
// 书源声明的类型(1 音频 / 2 图片)。加入书架时要带上它:写死成 0(文本)会让
|
||||
// 听书源的书以文本类型落库,阅读器就把播放直链当正文排版出来,根本播不了。
|
||||
useEffect(() => {
|
||||
if (!sourceURL) return
|
||||
readerAPI
|
||||
.listSources()
|
||||
.then((sources) => setSourceType(sources.find((s) => s.source_url === sourceURL)?.type ?? 0))
|
||||
.catch(() => undefined)
|
||||
}, [sourceURL])
|
||||
|
||||
// 详情返回的 tocUrl 更准确,拿到后重新拉目录
|
||||
useEffect(() => {
|
||||
const tocURL = info?.toc_url
|
||||
@@ -84,7 +96,7 @@ export default function ReaderBookPage() {
|
||||
source_id: originID,
|
||||
origin: sourceURL,
|
||||
origin_name: originName || info?.name || '',
|
||||
origin_type: 0,
|
||||
origin_type: sourceType,
|
||||
book_url: bookURL,
|
||||
latest_chapter: info?.latest_chapter ?? '',
|
||||
},
|
||||
|
||||
@@ -250,7 +250,11 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
|
||||
if (res.total === 0) {
|
||||
toast.success('没有需要更新的网络书籍', { id: toastId })
|
||||
} else if (res.failed > 0) {
|
||||
toast(`更新完成:${res.updated} 本有新章节,${res.failed} 本失败`, { id: toastId, icon: '⚠️' })
|
||||
// 只有抓取/写入真的失败才告警;「抓到但没有新章节」属正常结果,另算一档
|
||||
const parts = [`${res.updated} 本有新章节`]
|
||||
if (res.unchanged > 0) parts.push(`${res.unchanged} 本已是最新`)
|
||||
parts.push(`${res.failed} 本失败,可在日志中查看书源报错`)
|
||||
toast(`更新完成:${parts.join(',')}`, { id: toastId, icon: '⚠️' })
|
||||
} else {
|
||||
toast.success(res.updated > 0 ? `更新完成,${res.updated} 本有新章节` : '已是最新目录', { id: toastId })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -20,11 +20,13 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
|
||||
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:主题配色、点击区域、上下章、
|
||||
// 进度记忆、翻页/滚动双模式;桌面端限宽居中,支持键盘翻页)。
|
||||
@@ -193,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 || '加载失败')
|
||||
@@ -240,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,退出重进就从头开始。
|
||||
@@ -431,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 阅读页的「换源」) ──
|
||||
//
|
||||
@@ -488,19 +526,11 @@ 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') {
|
||||
@@ -524,7 +554,7 @@ export default function ReaderViewPage() {
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (contentType === 'audio') {
|
||||
goChapter(1)
|
||||
getReaderAudioEngine().next()
|
||||
return
|
||||
}
|
||||
if (contentType === 'image' && settings.pageMode === 'page') {
|
||||
@@ -727,27 +757,7 @@ export default function ReaderViewPage() {
|
||||
<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} /> : '本章没有可播放的音频'}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -66,13 +66,14 @@ const noGroups = buildBookGroupTabs(books, [])
|
||||
check('no groups → only 全部', noGroups.length === 1 && noGroups[0].id === ALL_GROUP_ID && noGroups[0].count === 4)
|
||||
|
||||
const tabs = buildBookGroupTabs(books, groups)
|
||||
check('tabs = 全部 + 未分组 + groups', tabs.map((t) => t.name).join(',') === '全部,未分组,科幻,在读')
|
||||
check('tabs = 全部 + groups + 未分组(未分组殿后)', tabs.map((t) => t.name).join(',') === '全部,科幻,在读,未分组')
|
||||
check('全部 counts all books', tabs[0].count === 4)
|
||||
check('未分组 counts unclaimed', tabs[1].count === 1)
|
||||
check('group counts only existing books', tabs[2].count === 2 && tabs[3].count === 1)
|
||||
check('ungrouped is the last tab', tabs[tabs.length - 1].kind === 'ungrouped')
|
||||
check('未分组 counts unclaimed', tabs[3].count === 1)
|
||||
check('group counts only existing books', tabs[1].count === 2 && tabs[2].count === 1)
|
||||
check('stale ids do not inflate counts', (() => {
|
||||
const withStale = buildBookGroupTabs(books, [{ name: '科幻', book_ids: ['a', 'ghost', 'ghost2'] }])
|
||||
return withStale[2].count === 1 && withStale[1].count === 3
|
||||
return withStale[1].count === 1 && withStale[2].count === 3
|
||||
})())
|
||||
|
||||
// ── 排序与选中回落 ──
|
||||
|
||||
@@ -204,7 +204,9 @@ export function filterBooksByGroup<T extends { id: string }>(
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成分组栏模型:首项固定「全部」,有分组时追加「未分组」,其后按分组顺序追加。
|
||||
* 生成分组栏模型:首项固定「全部」,其后按分组顺序追加,最后(有分组时)追加「未分组」。
|
||||
* 把「未分组」放到最后:它是一个兜底视图而不是一个真实分组,放在用户分组后面才不会
|
||||
* 把用户自己排的分组顺序挤开(用户拖拽排序只影响中间那段)。
|
||||
* 计数只统计当前书架上仍存在的书,避免分组里残留的死 ID 让数字虚高。
|
||||
*/
|
||||
export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[]): BookGroupTab[] {
|
||||
@@ -219,12 +221,6 @@ export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[])
|
||||
{ id: ALL_GROUP_ID, name: '全部', count: books.length, kind: 'all' },
|
||||
]
|
||||
if (groups.length === 0) return tabs
|
||||
tabs.push({
|
||||
id: UNGROUPED_GROUP_ID,
|
||||
name: '未分组',
|
||||
count: books.length - claimed.size,
|
||||
kind: 'ungrouped',
|
||||
})
|
||||
for (const group of groups) {
|
||||
tabs.push({
|
||||
id: group.name,
|
||||
@@ -233,6 +229,12 @@ export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[])
|
||||
kind: 'group',
|
||||
})
|
||||
}
|
||||
tabs.push({
|
||||
id: UNGROUPED_GROUP_ID,
|
||||
name: '未分组',
|
||||
count: books.length - claimed.size,
|
||||
kind: 'ungrouped',
|
||||
})
|
||||
return tabs
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user