M29 只解决了「回书架不重扫」;首次进书架仍要全量扫 434 目录(真机 3.0–3.4s, 其中 opendir+readdir 占 96%)。本单把扫描结果 + 每个扫描目录的 mtime 存到 MeowData/os.meow.reader/index/shelf.idx,冷启动逐目录 stat 比对 mtime,命中即 完全跳过 readdir;任何异常一律 fail-open 全量重扫。 先量再选(任务 1):宿主 434 目录/595 本/51 rar 虚拟库(贴真机量级) - 434 次目录 stat = 0.48ms/pass(1.1µs/次,不含 readdir); - 读 72KB 索引 = 0.009ms;全量扫描 3.54ms;索引命中 1.29ms(ts_fn=NULL)。 真机按 M28 实测 1079 次 stat=160–170ms(≈150µs/次)外推:434 次 ≈ 65ms, 远低于「可能不可行」的 1s 阈值 → 选「校验全部扫描目录 mtime」(非只 level-1)。 strace 断言:indexonly 载入 0 次 getdents64、恰好 434 次 stat(真的没 readdir)。 生产路径 ≈ 65ms(stat) + <20ms(索引读) + 40–50ms(rdprog×595) + <20ms(build_rows) ≈ 0.13–0.15s(对比 3.0s)。 索引格式 v1(手工小端序列化,不依赖结构体布局): header(magic/version/header_size/root_len) + root + dir 记录×(rel,mtime_s, mtime_ns,books_here) + book 记录×(rel,size,fmt) + trailer(ndirs,nbooks,flags, depth_skipped,crc32,magic)。truncated/depth_skipped 存在 flags/字段里,载入时 如实重建提示条;**不存阅读进度 ts**,载入后每本由 rdprog_load 现读再排序。 失效语义(存每个扫描过的目录,不只 level-1): - level-2 新增 / level-3 深处新增 / 删书 → 该目录 mtime 变 → 重扫; - 一级目录改名 → 父目录 mtime 变 + 旧目录 stat ENOENT → 重扫; - 超深(>6 层)未扫描子树不在索引内,其内部变化不自动失效(本来就不显示), 点「刷新」更新 depth_skipped 计数;mtime 精度受限(exFAT 10ms/FAT32 2s)的 同刻度变更也可能漏判,点「刷新」必可纠正。 M29 语义不变:首次进书架=索引或全扫;回书架=不扫;刷新=删索引+全扫(之后原子 写回新索引)。 安全/健壮(宁可慢不可错): - 写:shelf.idx.tmp 全量写完(尽力 fsync)后 rename;中途被杀只剩 tmp,正式索引 要么旧要么完整新;rename 覆盖失败先删旧再改名;fflush/写错即放弃,绝不影响书架。 - 读:O_NOFOLLOW|O_NONBLOCK + S_ISREG(FIFO/符号链接直接拒绝不阻塞);CRC32 覆盖 全部记录;rel 做绝对路径/“..”/内嵌 NUL/长度校验;版本/根路径/计数/逐目录 mtime 任一不符即重扫;载入崩溃路径全部 goto 清理,不改书架可用性。 - writer_open 失败(卡满/写保护/无 MeowData)→ 扫描不采集目录、照常出书; commit 失败(含只读目录 rename 失败)不留正式索引。 测试:test/shelf_index_smoke 154 pass(含四种变更场景失效、截断/字节翻转/垃圾/ 缺失/FIFO/符号链接/版本不符/根路径不符、ts 现读、truncated/depth_skipped 重建、 只读目录写失败、空 Books、root 缺失、.tmp 残渣),ASan+UBSan 干净; test/shelf_index_bench 模拟 434 目录/595 本/51 rar:scan/load 均 595 本、 truncated=0/depth_skipped=0、索引 72,156B。build_reader.sh 全绿 0 warning (core 6846 / seg 19 / chapter 71 / shelf_group 63 / index 154 / font 14 / loaders PASS)。shelf_bench_ondev 增加 index_load/scan_save 计时与 MEOW_BENCH_IDX=miss(真机首开命中 vs 未命中对比,待有卡复核)。 交付:app.json 0.1.4/code5 → 0.1.5/code6;reader md5 df4cadcf 1,711,924B;mpk md5 fec7b1bb 814,535B;未签名不推卡。
514 lines
24 KiB
C
514 lines
24 KiB
C
/* app/shelf.c — 书架:扫 TF 卡 `Books/`,识别 TXT/FB2/EPUB/MOBI/CBZ(R1 仅 TXT 可开,
|
||
* 其它显示「R2 支持」占位);MD3 布局(meow_topappbar + list/grid)+ 最近阅读置顶 + 段头分组。
|
||
*
|
||
* 布局(README §3.2,用户 2026-09-04 拍板):
|
||
* - 默认 list(meow_listview_set_container(on) MD3 圆角卡;**行数 > SHELF_ROUND_MAX_ROWS
|
||
* 时自动降级为扁平行**——圆角卡逐像素填充太慢,见 shelf_build_content 注释),
|
||
* 可切 grid(meow_lazygrid 4 列封面卡);布局存 reader.conf `ui.shelf_layout`;
|
||
* - 顶栏 meow_topappbar:标题「书架」+ 动作(布局切换 / 分组切换 / 刷新 / App 设置入口);
|
||
* - 无书也渲染完整骨架(顶栏 + 内容区 + 居中空态卡 + 指引 + 支持格式);
|
||
* - 封面:MVP 无封面提取(engine F1);无则圆角占位(surfaceContainerHigh + 格式徽标)。
|
||
*
|
||
* M27 分组(`ui.shelf_group`,默认 format 保持旧行为):
|
||
* format = 按格式分段(TXT/FB2/EPUB/MOBI/CBZ);
|
||
* folder = 按 Books/ 下的「第一级目录」分段(根下=未分类),更深子路径(分类/作品)进副题。
|
||
* 扫描/行序列在 app/shelf_model.c(纯 C,宿主可测);本文件只做控件装配与绘制。
|
||
*
|
||
* M29 性能(用户反馈「几百本书首页卡得飞起」):
|
||
* - **扫描结果进程内缓存**:只在本进程首次进入书架时扫 Books/;从阅读页返回不再重扫
|
||
* (真机 exFAT 全量扫描 3.1s,是「回书架冻结」的唯一大头)。顶栏「刷新」强制重扫。
|
||
* 行为变化:新放进 Books/ 的书需点一次「刷新」才出现。
|
||
* - **行数多时关圆角容器**(见 SHELF_ROUND_MAX_ROWS):圆角卡逐像素填充,595 本时滑一步 46ms。
|
||
* M30:M29 的「刚读完不置顶」缺口补上(只刷一本书的 ts + 内存重排,不重扫全盘)。
|
||
* M31:置顶时一并重算这本的副题(shelf_fmt_sub),「· 读」立即出现,不等下一次全量刷新;
|
||
* 顶栏「刷新」同时丢弃字体路径缓存(新拷入的字库此时生效)。
|
||
* M32:书架索引落盘(app/shelf_index.c)。冷启动先试 `MeowData/os.meow.reader/index/shelf.idx`:
|
||
* 版本/CRC/root 路径/全部目录 mtime 校验通过 → 直接出书,**完全跳过 readdir**
|
||
* (真机全量扫描 3.0s 的 96% 是 434 个目录的 opendir+readdir;校验只做 434 次 stat);
|
||
* 任何异常 → 照常全量重扫(fail-open)。扫描完成后原子写回索引(写失败只丢缓存,不影响书架)。
|
||
* M29 语义不变:首次进书架=索引或全扫;从阅读页返回=不扫;顶栏「刷新」=清索引+全扫。
|
||
*/
|
||
#include "../reader_internal.h"
|
||
#include "font_resolve.h"
|
||
#include "progress.h"
|
||
#include "reader_conf.h"
|
||
#include "shelf_index.h"
|
||
#include "shelf_model.h"
|
||
#include "meow_ui_internal.h" /* meow_gfx_fill_round(宫格卡圆角填充) */
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
|
||
#define SHELF_APPBAR_H 56
|
||
#define SHELF_LIST_X 0
|
||
#define SHELF_LIST_W 480
|
||
/* 圆角卡容器阈值:行数 ≤ 该值用 MD3 圆角卡(观感优先),超过则扁平行(滚动流畅优先)。 */
|
||
#define SHELF_ROUND_MAX_ROWS 200
|
||
|
||
static shelf_book g_books[SHELF_MAX_BOOKS];
|
||
static int g_nbooks;
|
||
static int g_scanned; /* 1=本进程已扫过 Books/(进书架缓存;「刷新」动作强制重扫) */
|
||
static int g_active;
|
||
/* M30:刚打开过的书(返回书架时只刷这一本的 ts 并重排,避免为「最近阅读置顶」重扫全盘)。 */
|
||
static char g_just_read[400];
|
||
static meow_widget *g_list; /* listview(布局=list) */
|
||
static meow_lazygrid *g_grid; /* lazygrid(布局=grid) */
|
||
static meow_widget *g_appbar; /* meow_topappbar */
|
||
static int g_layout; /* 0=list 1=grid(读 rdconf->shelf) */
|
||
static int g_group_folder;/* 0=format 1=folder(读 rdconf->shelf_group) */
|
||
static uint32_t g_bg, g_fg, g_dim;
|
||
static char g_notice[160]; /* 提示第 1 行(opendir 失败/超上限截断等) */
|
||
static char g_notice2[160]; /* 提示第 2 行(超量+超深并存时才用;空=单行) */
|
||
static int g_notice_ms; /* 1=有提示(0=无) */
|
||
static uint64_t g_notice_until; /* 提示到期时刻(meow_ui_time_ms;6s 后自动消失) */
|
||
|
||
/* 段头行(list 模式):[段头, 书, 书, ..., 段头, ...];SHELF_MAX_ROWS=2×书上限(见 shelf_model.h)。 */
|
||
static shelf_row g_rows[SHELF_MAX_ROWS];
|
||
static int g_nrows;
|
||
|
||
static const char *fmt_name(int f) { return shelf_fmt_name(f); }
|
||
|
||
/* 进度时间戳(分组/排序用;不改 path,故进度键不受分组影响)。 */
|
||
static uint64_t shelf_ts_of(const char *path)
|
||
{
|
||
rdprog p;
|
||
return (rdprog_load(path, &p) == 1) ? p.ts : 0;
|
||
}
|
||
|
||
static void notice_set(const char *line1, const char *line2)
|
||
{
|
||
snprintf(g_notice, sizeof g_notice, "%s", line1 ? line1 : "");
|
||
snprintf(g_notice2, sizeof g_notice2, "%s", line2 ? line2 : "");
|
||
g_notice_ms = 1;
|
||
g_notice_until = meow_ui_time_ms() + 6000; /* 6s 后由 shelf_on_tick 清掉,不常驻压列表 */
|
||
}
|
||
|
||
/* 扫描/索引载入共用的报告 → 提示条文案(M29 语义不变)。
|
||
* 索引里存了 truncated/depth_skipped,载入时如实重建,避免「用索引打开」后提示失真。 */
|
||
static void notice_from_report(const shelf_scan_report *rep)
|
||
{
|
||
const char *dir = READER_SD_ROOT "/Books";
|
||
g_notice[0] = 0;
|
||
g_notice2[0] = 0;
|
||
g_notice_ms = 0;
|
||
if (rep->root_missing) {
|
||
char line[160];
|
||
snprintf(line, sizeof line, "未找到 %s 目录,请放入书籍(TXT)", dir);
|
||
notice_set(line, NULL);
|
||
} else if (rep->truncated && rep->depth_skipped > 0) {
|
||
/* 长句拆两行(480px 单行会被切掉) */
|
||
char l1[160], l2[160];
|
||
snprintf(l1, sizeof l1, "书籍超过 %d 本,已只显示前 %d 本", SHELF_MAX_BOOKS, SHELF_MAX_BOOKS);
|
||
snprintf(l2, sizeof l2, "另有 %d 个文件目录超过 %d 层未显示",
|
||
rep->depth_skipped, SHELF_SCAN_MAX_DEPTH);
|
||
notice_set(l1, l2);
|
||
} else if (rep->truncated) {
|
||
char line[160];
|
||
snprintf(line, sizeof line, "书籍超过 %d 本,已只显示前 %d 本",
|
||
SHELF_MAX_BOOKS, SHELF_MAX_BOOKS);
|
||
notice_set(line, NULL);
|
||
} else if (rep->depth_skipped > 0) {
|
||
char line[160];
|
||
snprintf(line, sizeof line, "有 %d 个文件因目录层数超过 %d 层未显示",
|
||
rep->depth_skipped, SHELF_SCAN_MAX_DEPTH);
|
||
notice_set(line, NULL);
|
||
}
|
||
}
|
||
|
||
/* 全量扫描 Books/ 并写回索引(首次无索引 / 刷新 / 索引校验失败时走这里)。
|
||
* 索引写盘是纯附加:open 失败(卡满/写保护/无 MeowData)→ 传 NULL 闭包,扫描照常;
|
||
* commit 失败只丢缓存。**索引问题绝不影响书架能否打开**。 */
|
||
static void scan_books_full(void)
|
||
{
|
||
const char *dir = READER_SD_ROOT "/Books";
|
||
shelf_scan_report rep;
|
||
/* 扫描时把每个目录的 mtime 直接流进索引临时文件,不占额外内存、也不二次 readdir。 */
|
||
shelf_idx_writer *w = shelf_index_writer_open(dir);
|
||
g_nbooks = shelf_scan_books_ex(dir, g_books, SHELF_MAX_BOOKS, shelf_ts_of, &rep,
|
||
w ? shelf_index_writer_dir : NULL, w);
|
||
if (w) shelf_index_writer_commit(w, g_books, g_nbooks, &rep); /* 成功/失败都接管 w */
|
||
if (rep.root_missing) shelf_index_remove(dir); /* 卡/目录没了:旧索引作废,别留陈旧缓存 */
|
||
g_scanned = 1;
|
||
notice_from_report(&rep);
|
||
}
|
||
|
||
/* 首次进入书架:先试索引(命中=只 stat 目录 + 读进度,不 readdir);任何异常退回全扫。 */
|
||
static void scan_books_first(void)
|
||
{
|
||
const char *dir = READER_SD_ROOT "/Books";
|
||
shelf_scan_report rep;
|
||
if (shelf_index_load(dir, g_books, SHELF_MAX_BOOKS, shelf_ts_of, &rep)) {
|
||
g_nbooks = rep.nbooks;
|
||
g_scanned = 1;
|
||
notice_from_report(&rep);
|
||
return;
|
||
}
|
||
scan_books_full();
|
||
}
|
||
|
||
/* 排队行:folder 模式按目录分段,否则按格式(默认)。 */
|
||
static void build_rows(void)
|
||
{
|
||
g_nrows = shelf_build_rows(g_books, g_nbooks, g_group_folder, g_rows, SHELF_MAX_ROWS);
|
||
}
|
||
|
||
/* M30:从阅读页返回时把「刚读完」置顶。只读这一本的进度文件 + 重排内存里的 g_books,
|
||
* 不 opendir/不遍历目录(对比全量重扫真机 3s)。重排后 on_enter 照常 build_rows + 重建控件。
|
||
* M31:置顶的同时重建这本的副题——只改 ts 不重建的话,副题的「· 读」要等下次全量刷新
|
||
* (scan_books 重新格式化)才出现,表现就是「刚读完却没显示已读」。 */
|
||
static void promote_just_read(void)
|
||
{
|
||
if (!g_just_read[0]) return;
|
||
for (int i = 0; i < g_nbooks; i++) {
|
||
if (strcmp(g_books[i].path, g_just_read) != 0) continue;
|
||
uint64_t ts = shelf_ts_of(g_books[i].path);
|
||
if (ts > g_books[i].ts) {
|
||
g_books[i].ts = ts;
|
||
shelf_fmt_sub(&g_books[i]); /* 副题随 ts 重算,「· 读」立即出现 */
|
||
shelf_sort_recent(g_books, g_nbooks);
|
||
}
|
||
break;
|
||
}
|
||
g_just_read[0] = 0;
|
||
}
|
||
|
||
/* ---- list 数据源(vsource + 段头;container on) ---- */
|
||
static int shelf_list_count(void *ctx) { (void)ctx; return g_nrows; }
|
||
static void shelf_list_bind(int idx, meow_lv_item *item, void *ctx)
|
||
{
|
||
(void)ctx;
|
||
item->icon = NULL;
|
||
item->icon_kind = MEOW_LIST_ICON_NONE;
|
||
item->right = MEOW_LIST_RIGHT_NONE;
|
||
item->on = 0;
|
||
item->enabled = 1;
|
||
item->user = NULL;
|
||
item->title = NULL;
|
||
item->subtitle = NULL;
|
||
item->section = 0;
|
||
if (idx < 0 || idx >= g_nrows) return;
|
||
if (g_rows[idx].section) {
|
||
item->section = 1;
|
||
item->title = g_rows[idx].label ? g_rows[idx].label : fmt_name(g_rows[idx].fmt);
|
||
item->enabled = 0;
|
||
} else {
|
||
int bi = g_rows[idx].book;
|
||
if (bi < 0 || bi >= g_nbooks) return;
|
||
item->title = g_books[bi].title;
|
||
item->subtitle = g_group_folder ? g_books[bi].sub_folder : g_books[bi].sub;
|
||
item->icon_kind = MEOW_LIST_ICON_FILE;
|
||
item->right = MEOW_LIST_RIGHT_ARROW;
|
||
}
|
||
}
|
||
static void shelf_list_tapcb(int idx, int op, void *ctx)
|
||
{
|
||
(void)op; (void)ctx;
|
||
if (idx < 0 || idx >= g_nrows || g_rows[idx].section) return;
|
||
int bi = g_rows[idx].book;
|
||
if (bi < 0 || bi >= g_nbooks) return;
|
||
/* 点书开读:reader_open 打开书(含页位图)→ push ACT_READER 让阅读页成为 view activity,
|
||
* 驱动 reader_on_enter/on_leave(重建/释放页位图);「返回书架」/左缘返回走 page_pop 回书架。 */
|
||
if (reader_open(g_books[bi].path) == 0) {
|
||
snprintf(g_just_read, sizeof g_just_read, "%s", g_books[bi].path); /* 返回时置顶用 */
|
||
meow_view_page_push(ACT_READER);
|
||
}
|
||
}
|
||
|
||
/* ---- grid 数据源(meow_lazygrid,4 列封面卡) ---- */
|
||
static int shelf_grid_count(void *ctx) { (void)ctx; return g_nbooks; }
|
||
static void shelf_grid_bind(int idx, meow_lazygrid_item *item, void *ctx)
|
||
{
|
||
(void)ctx;
|
||
item->title = NULL; item->subtitle = NULL; item->icon = NULL;
|
||
item->enabled = 1; item->user = NULL;
|
||
if (idx < 0 || idx >= g_nbooks) return;
|
||
item->title = g_books[idx].title;
|
||
item->subtitle = g_books[idx].sub;
|
||
}
|
||
/* 标题截断(格内防溢出):按字节收窄(UTF-8 截断可能断码,但仅显示用,断码由字库兜底为 tofu;
|
||
* 更稳妥在这里按格子宽度自适应——MVP 简单截 8 字节 + "…")。 */
|
||
static void grid_title_cut(const char *src, char *out, int outsz)
|
||
{
|
||
int n = 0;
|
||
while (src[n] && n < (int)sizeof g_books[0].title - 1 && n < 7 * 3) n++; /* ≤7 个 3 字节字 */
|
||
if ((size_t)n >= strlen(src)) { strncpy(out, src, (size_t)outsz - 1); out[outsz - 1] = 0; return; }
|
||
int m = n; while (m > 0 && ((unsigned char)src[m]) > 0x7F && m > n - 2) m--; /* 断码回退 */
|
||
if (m < 0) m = 0;
|
||
strncpy(out, src, (size_t)m); out[m] = 0;
|
||
size_t L = strlen(out);
|
||
if (L + 4 < (size_t)outsz) { out[L] = ' '; out[L + 1] = ' '; out[L + 2] = '.'; out[L + 3] = '.'; out[L + 4] = '.';
|
||
out[L + 5] = 0; }
|
||
}
|
||
static void shelf_grid_draw(int index, int x, int y, int w, int h, int state, void *ctx)
|
||
{
|
||
(void)ctx;
|
||
const meow_theme *th = meow_theme_get();
|
||
if (index < 0 || index >= g_nbooks) return;
|
||
const shelf_book *b = &g_books[index];
|
||
uint32_t card = th->surfaceContainerLow;
|
||
if (state & MEOW_LAZYGRID_ST_SELECTED) card = th->primaryContainer;
|
||
meow_gfx_fill_round(x, y, w, h, th->radius_card, card);
|
||
int pad = 8;
|
||
int cw = w - 2 * pad, chh = h - 2 * pad - meow_ui_text_height(16) - 6;
|
||
int cx = x + pad, cy = y + pad;
|
||
if (cw > 0 && chh > 0) {
|
||
meow_gfx_fill_round(cx, cy, cw, chh, MEOW_SHAPE_SMALL, th->surfaceContainerHigh);
|
||
/* 格式徽标(居中) */
|
||
char fc[16]; int fs = 24;
|
||
snprintf(fc, sizeof fc, "%s", fmt_name(b->fmt));
|
||
meow_ui_text_center(cx + cw / 2, cy + (chh - meow_ui_text_height(fs)) / 2, fc, fs, th->primary);
|
||
}
|
||
char t[128]; grid_title_cut(b->title, t, sizeof t);
|
||
meow_ui_text_center(x + w / 2, y + h - meow_ui_text_height(16) - 2, t, 16, th->onSurface);
|
||
if (state & MEOW_LAZYGRID_ST_SELECTED) {
|
||
meow_gfx_fill_round_alpha(x, y, w, h, th->radius_card, th->primary, 26);
|
||
}
|
||
}
|
||
static void shelf_grid_tapcb(int idx, void *ctx)
|
||
{
|
||
(void)ctx;
|
||
if (idx < 0 || idx >= g_nbooks) return;
|
||
if (reader_open(g_books[idx].path) == 0) {
|
||
snprintf(g_just_read, sizeof g_just_read, "%s", g_books[idx].path); /* 返回时置顶用 */
|
||
meow_view_page_push(ACT_READER);
|
||
}
|
||
}
|
||
|
||
/* ---- 控件装配(按当前布局重建 list / grid) ---- */
|
||
static void shelf_build_content(void)
|
||
{
|
||
const meow_theme *th = meow_theme_get();
|
||
int sbh = meow_ui_statusbar_height();
|
||
int top = sbh + SHELF_APPBAR_H;
|
||
int list_y = top + 22; /* 副题区下 */
|
||
int list_h = 720 - list_y;
|
||
if (list_h < 0) list_h = 0;
|
||
|
||
if (g_list) { meow_widget_free(g_list); g_list = NULL; }
|
||
if (g_grid) { meow_lazygrid_free(g_grid); g_grid = NULL; }
|
||
|
||
if (g_layout == 1) { /* grid */
|
||
int cols = 4, gap = 8;
|
||
int cw = (SHELF_LIST_W - (cols + 1) * 8) / cols; /* 16 边距 */
|
||
int chh = cw + 30; /* 封面 + 标题 */
|
||
g_grid = meow_lazygrid_create(SHELF_LIST_X, list_y, SHELF_LIST_W, list_h, cols, cw, chh, gap);
|
||
if (g_grid) {
|
||
meow_lazygrid_set_vsource(g_grid, shelf_grid_count, shelf_grid_bind, NULL);
|
||
meow_lazygrid_set_item_draw(g_grid, shelf_grid_draw, NULL);
|
||
meow_lazygrid_set_on_tap(g_grid, shelf_grid_tapcb, NULL);
|
||
}
|
||
} else { /* list */
|
||
g_list = meow_listview_create(SHELF_LIST_X, list_y, SHELF_LIST_W, list_h,
|
||
g_nrows, NULL, NULL, MEOW_LIST_SEL_SINGLE);
|
||
if (g_list) {
|
||
meow_listview_set_vsource(g_list, shelf_list_count, shelf_list_bind, NULL);
|
||
/* MD3 圆角容器(surfaceContainer 卡)走 meow_gfx_fill_round 逐像素 rr_inside:
|
||
* 真机一张 464×54 卡 3.8–6.4ms、可见 11 行≈42ms/帧(滑一步 46ms)。行数多时
|
||
* 自动降级为扁平行(survey 实测同滚动 14ms),行数少(≤SHELF_ROUND_MAX_ROWS)
|
||
* 仍保留圆角卡观感。 */
|
||
meow_listview_set_container(g_list, g_nrows > SHELF_ROUND_MAX_ROWS ? 0 : 1);
|
||
meow_listview_set_item_tier(g_list, 2);
|
||
meow_listview_set_on_tap(g_list, shelf_list_tapcb, NULL);
|
||
}
|
||
}
|
||
(void)th;
|
||
}
|
||
|
||
/* 顶栏动作:0=布局切换 1=分组切换 2=刷新 3=设置。动作标签 = 将切换到的模式。 */
|
||
static void shelf_appbar_build(void)
|
||
{
|
||
int sbh = meow_ui_statusbar_height();
|
||
if (g_appbar) { meow_widget_free(g_appbar); g_appbar = NULL; }
|
||
g_appbar = meow_topappbar_create(0, sbh, 480, SHELF_APPBAR_H, "书架");
|
||
if (!g_appbar) return;
|
||
meow_topappbar_add_action(g_appbar, g_layout ? "列表" : "宫格"); /* 0 布局切换 */
|
||
meow_topappbar_add_action(g_appbar, g_group_folder ? "格式" : "目录"); /* 1 分组切换 */
|
||
meow_topappbar_add_action(g_appbar, "刷新"); /* 2 刷新 */
|
||
meow_topappbar_add_action(g_appbar, "设置"); /* 3 设置 */
|
||
meow_topappbar_set_item_icon(g_appbar, 2, MEOW_ICON_REFRESH);
|
||
meow_topappbar_set_item_icon(g_appbar, 3, MEOW_ICON_SETTINGS);
|
||
}
|
||
|
||
/* view 生命周期 */
|
||
static void shelf_on_create(meow_view *v, void *ctx) { (void)v; (void)ctx; }
|
||
static void shelf_on_enter(meow_view *v, void *ctx)
|
||
{
|
||
(void)v; (void)ctx;
|
||
g_active = 1;
|
||
/* 先读配置(build_rows 依赖分组模式),再扫描/排队。 */
|
||
const rdconf *c = rdconf_get();
|
||
g_layout = (c && c->shelf[0] && strcmp(c->shelf, "grid") == 0) ? 1 : 0;
|
||
g_group_folder = (c && strcmp(c->shelf_group, "folder") == 0) ? 1 : 0;
|
||
/* 书架缓存:本进程只在首次进入时取一次 Books/(从阅读页返回不再重扫)。
|
||
* M32:首次先试索引(命中=只 stat 目录,跳过 readdir);索引不可用才全量扫描。
|
||
* 扫描/校验在真机是 3.1s / ~0.1s 的差别(exFAT 434 目录 opendir/readdir 是扫描的唯一大头)。
|
||
* 代价:进程内不再自动发现新书,需点顶栏「刷新」;跨进程重启则由索引 mtime 校验兜底。
|
||
* M30:刚读完那本例外——promote_just_read() 只读它一个进度文件并重排,低成本置顶。
|
||
* 提示条(超上限截断 / 超深目录)语义不变:索引里存了这两个值,载入时如实重建。 */
|
||
if (!g_scanned) scan_books_first();
|
||
else promote_just_read();
|
||
build_rows();
|
||
const meow_theme *th = meow_theme_get();
|
||
g_bg = th->bg; g_fg = th->fg; g_dim = th->dim;
|
||
|
||
shelf_appbar_build();
|
||
shelf_build_content();
|
||
shelf_redraw();
|
||
}
|
||
static void shelf_on_leave(meow_view *v, void *ctx) { (void)v; (void)ctx; g_active = 0; }
|
||
static void shelf_on_destroy(meow_view *v, void *ctx)
|
||
{
|
||
(void)v; (void)ctx;
|
||
if (g_list) { meow_widget_free(g_list); g_list = NULL; }
|
||
if (g_grid) { meow_lazygrid_free(g_grid); g_grid = NULL; }
|
||
if (g_appbar) { meow_widget_free(g_appbar); g_appbar = NULL; }
|
||
}
|
||
|
||
meow_view *shelf_view_create(void)
|
||
{
|
||
static meow_view_hooks hooks = { shelf_on_create, shelf_on_enter, shelf_on_leave,
|
||
shelf_on_destroy, NULL, NULL, NULL };
|
||
return meow_view_create(ACT_SHELF, MEOW_VIEW_ACTIVITY, &hooks, NULL);
|
||
}
|
||
|
||
int shelf_is_active(void) { return g_active; }
|
||
|
||
/* 书架 tick(app.c 在书架为前台页时调用):提示条 6s 到期自动消失。 */
|
||
void shelf_on_tick(uint64_t now)
|
||
{
|
||
if (g_notice_ms > 0 && now >= g_notice_until) {
|
||
g_notice_ms = 0;
|
||
g_notice_until = 0;
|
||
shelf_redraw();
|
||
}
|
||
}
|
||
|
||
/* 切换 list/grid(书架顶栏「布局切换」动作;改 reader.conf 并重建)。 */
|
||
void shelf_set_view(int grid)
|
||
{
|
||
rdconf *c = rdconf_get();
|
||
if (!c) return;
|
||
strcpy(c->shelf, grid ? "grid" : "list");
|
||
rdconf_persist();
|
||
g_layout = grid ? 1 : 0;
|
||
if (g_active) {
|
||
shelf_appbar_build();
|
||
shelf_build_content();
|
||
shelf_redraw();
|
||
}
|
||
}
|
||
|
||
/* 切换 format/folder(顶栏「分组」动作 / 设置页「书架分组」;改 reader.conf 并重建)。
|
||
* 只改分组模式:不碰 books[].path,故进度键 rdprog_hash(path) 不变。 */
|
||
void shelf_set_group(int folder)
|
||
{
|
||
rdconf *c = rdconf_get();
|
||
if (!c) return;
|
||
strcpy(c->shelf_group, folder ? "folder" : "format");
|
||
rdconf_persist();
|
||
g_group_folder = folder ? 1 : 0;
|
||
if (g_active) {
|
||
build_rows();
|
||
shelf_appbar_build();
|
||
shelf_build_content();
|
||
shelf_redraw();
|
||
}
|
||
}
|
||
|
||
void shelf_redraw(void)
|
||
{
|
||
const meow_theme *th = meow_theme_get();
|
||
g_bg = th->bg; g_fg = th->fg; g_dim = th->dim;
|
||
meow_ui_clear(g_bg);
|
||
if (g_appbar) meow_widget_draw(g_appbar);
|
||
|
||
/* 副题:Books/ · N 本 [· 按目录](分组只作用于列表;宫格仍是平铺封面墙) */
|
||
if (g_nbooks > 0) {
|
||
char sub[96];
|
||
if (g_group_folder && g_layout == 0)
|
||
snprintf(sub, sizeof sub, "Books/ · %d 本 · 按目录", g_nbooks);
|
||
else snprintf(sub, sizeof sub, "Books/ · %d 本", g_nbooks);
|
||
int sbh = meow_ui_statusbar_height();
|
||
meow_ui_text(24, sbh + SHELF_APPBAR_H + 3, sub, 16, g_dim);
|
||
}
|
||
|
||
if (g_nbooks > 0) {
|
||
if (g_list) meow_widget_draw(g_list);
|
||
if (g_grid) meow_lazygrid_draw(g_grid, th);
|
||
} else {
|
||
/* 无书也渲染完整骨架(顶栏已在)+ 居中空态卡 + 指引 + 支持格式 */
|
||
int sbh = meow_ui_statusbar_height();
|
||
int top = sbh + SHELF_APPBAR_H;
|
||
int area_h = 720 - top;
|
||
int cw = 360, chh = 200;
|
||
int cx = (480 - cw) / 2;
|
||
int cy = top + (area_h - chh) / 2;
|
||
if (cy < top) cy = top;
|
||
meow_gfx_fill_round(cx, cy, cw, chh, th->radius_card, th->surfaceContainer);
|
||
meow_ui_text_center(480 / 2, cy + 40, "书架为空", 28, th->fg);
|
||
meow_ui_text_center(480 / 2, cy + 76, "请往 TF 卡 Books/ 放书籍", 20, th->onSurfaceVariant);
|
||
meow_ui_text_center(480 / 2, cy + 104, "支持 TXT / FB2 / EPUB / MOBI / CBZ", 20, th->onSurfaceVariant);
|
||
/* 「去放书」文本按钮(提示) */
|
||
meow_ui_text_center(480 / 2, cy + 150, "Books/ 目录", 20, th->primary);
|
||
}
|
||
if (g_notice_ms > 0) {
|
||
int two = g_notice2[0] != 0;
|
||
int top = two ? 610 : 620;
|
||
int h = two ? 58 : 44;
|
||
meow_ui_fill_rect(0, top, 480, h, g_bg);
|
||
meow_ui_text(20, top + 4, g_notice, 20, th->primary);
|
||
if (two) meow_ui_text(20, top + 30, g_notice2, 20, th->primary);
|
||
}
|
||
meow_ui_flush();
|
||
}
|
||
|
||
int shelf_on_tap(int x, int y)
|
||
{
|
||
/* 顶栏动作先命中 */
|
||
if (g_appbar) {
|
||
int bi = meow_topappbar_hit(g_appbar, x, y);
|
||
if (bi == 0) { /* 布局切换 */
|
||
shelf_set_view(g_layout ? 0 : 1);
|
||
return 1;
|
||
} else if (bi == 1) { /* 分组切换:format ↔ folder */
|
||
shelf_set_group(g_group_folder ? 0 : 1);
|
||
return 1;
|
||
} else if (bi == 2) { /* 刷新:清索引 + 全量重扫(新书/改名/删书在此出现) */
|
||
g_scanned = 0;
|
||
font_cache_invalidate(); /* 字体路径记忆化也一并失效(新拷入的字库在此生效) */
|
||
/* 先删索引再全量扫描:即使旧索引 mtime 校验碰巧通过(如文件系统时间戳精度不足),
|
||
* 刷新也一定重扫;扫描成功后写回新索引(写失败只丢缓存)。 */
|
||
shelf_index_remove(READER_SD_ROOT "/Books");
|
||
scan_books_full();
|
||
build_rows();
|
||
shelf_build_content();
|
||
shelf_redraw();
|
||
return 1;
|
||
} else if (bi == 3) { /* App 设置入口 */
|
||
meow_view_page_push(ACT_SETTINGS);
|
||
return 1;
|
||
}
|
||
}
|
||
if (g_nbooks <= 0) { shelf_redraw(); return 0; }
|
||
if (g_layout == 1 && g_grid) {
|
||
int idx = meow_lazygrid_tap_at(g_grid, x, y);
|
||
if (idx >= 0) shelf_redraw();
|
||
return 0;
|
||
}
|
||
if (g_list) {
|
||
int idx = meow_listview_tap_at(g_list, x, y);
|
||
if (idx >= 0) shelf_redraw();
|
||
return 0;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* 书架的滚动(list/grid;app.c on_swipe 路由到本页时调用)。 */
|
||
void shelf_on_swipe(int dir, int dx, int dy)
|
||
{
|
||
(void)dir; (void)dx;
|
||
if (g_nbooks <= 0) return;
|
||
if (g_layout == 1 && g_grid) { meow_lazygrid_scroll_by(g_grid, dy); meow_lazygrid_draw(g_grid, meow_theme_get()); meow_ui_flush(); }
|
||
else if (g_list) { meow_listview_scroll_by(g_list, dy); meow_widget_draw(g_list); meow_ui_flush(); }
|
||
}
|