- launcher(453): app_launcher.c g_launch_fail_reason+launch_fail()统一出口(5失败点 fork OOM/exec/no such file/missing dep/execpipe)+main_menu.c launch_fail_toast(缺依赖/CANNOT START: name(reason))+toast256 - launcher(457): bg_page.c bgm_enumerate跳过term_at_ms(杀后当下消失)+不可杀行按钮置灰+row_tap/top_action返回1; subpage.c 消warning - reader(454): reader_on_swipe LEFT/RIGHT→返回(先关overlay否则page_pop回书架)+app.c书架根LEFT→exit - reader(458瘦身A+B): app.json fonts full→common(省13MB atlas)+reader.c RD_PAGE_CACHE 3→2+g_anim_old懒分配/anim_done释放+on_enter/on_leave页位图离开释放重建 - reader(459导航): reader_open返回int+shelf点书push ACT_READER(阅读页接入view栈,on_enter/on_leave驱动) — 使454左缘返回+458瘦身B真正生效 - files(455): viewer.c viewer_cp_len+按码位折行(txt中文不冲出右缘) 0warning;宿主测试全过;files二进制已热更
418 lines
17 KiB
C
418 lines
17 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 圆角卡),可切 grid(meow_lazygrid 4 列封面卡);
|
||
* 布局存 reader.conf `ui.shelf_layout`(rdconf_get()->shelf);
|
||
* - 顶栏 meow_topappbar:标题「书架」+ 动作(布局切换 / 刷新 / App 设置入口);
|
||
* - 无书也渲染完整骨架(顶栏 + 内容区 + 居中空态卡 + 指引 + 支持格式);
|
||
* - 封面:MVP 无封面提取(engine F1);无则圆角占位(surfaceContainerHigh + 格式徽标)。
|
||
*/
|
||
#include "../reader_internal.h"
|
||
#include "progress.h"
|
||
#include "reader_conf.h"
|
||
#include "meow_ui_internal.h" /* meow_gfx_fill_round(宫格卡圆角填充) */
|
||
#include <dirent.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <sys/stat.h>
|
||
|
||
#define SHELF_APPBAR_H 56
|
||
#define SHELF_LIST_X 0
|
||
#define SHELF_LIST_W 480
|
||
|
||
typedef struct shelf_book {
|
||
char path[400];
|
||
char title[128];
|
||
char sub[160];
|
||
int fmt; /* 0 txt,1 fb2,2 epub,3 mobi,4 cbz */
|
||
long size;
|
||
uint64_t ts; /* 最近阅读 unix;无=0 */
|
||
} shelf_book;
|
||
|
||
static shelf_book g_books[256];
|
||
static int g_nbooks;
|
||
static int g_active;
|
||
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 uint32_t g_bg, g_fg, g_dim;
|
||
static char g_notice[160]; /* 提示(opendir 失败等) */
|
||
static int g_notice_ms; /* 剩余显示 ms(0=无) */
|
||
|
||
/* 段头行(list 模式):把扫描结果排队为 [段头(fmt), 书, 书, ..., 段头, ...]。
|
||
* 书量小用段头(README §3.2),格式分组(TXT/FB2/EPUB/MOBI/CBZ)。 */
|
||
#define SHELF_MAX_ROWS 520
|
||
typedef struct shelf_row { char section; int fmt; int book; } shelf_row;
|
||
static shelf_row g_rows[SHELF_MAX_ROWS];
|
||
static int g_nrows;
|
||
|
||
static const char *fmt_name(int f)
|
||
{
|
||
switch (f) { case 0: return "TXT"; case 1: return "FB2"; case 2: return "EPUB";
|
||
case 3: return "MOBI"; case 4: return "CBZ"; default: return "?"; }
|
||
}
|
||
static int fmt_from_ext(const char *name)
|
||
{
|
||
const char *d = strrchr(name, '.');
|
||
if (!d) return -1;
|
||
if (strcasecmp(d, ".txt") == 0) return 0;
|
||
if (strcasecmp(d, ".fb2") == 0) return 1;
|
||
if (strcasecmp(d, ".epub") == 0) return 2;
|
||
if (strcasecmp(d, ".mobi") == 0) return 3;
|
||
if (strcasecmp(d, ".cbz") == 0) return 4;
|
||
return -1;
|
||
}
|
||
static void fmt_size(char *out, int outsz, long s)
|
||
{
|
||
if (s >= 1 << 20) snprintf(out, outsz, "%.1fMB", (double)s / (1 << 20));
|
||
else if (s >= 1 << 10) snprintf(out, outsz, "%.0fKB", (double)s / (1 << 10));
|
||
else snprintf(out, outsz, "%ldB", s);
|
||
}
|
||
|
||
static int cmp_recent_then_name(const void *a, const void *b)
|
||
{
|
||
const shelf_book *x = (const shelf_book *)a, *y = (const shelf_book *)b;
|
||
if (x->ts != y->ts) return (x->ts < y->ts) ? 1 : -1; /* 最近在前 */
|
||
return strcasecmp(x->title, y->title);
|
||
}
|
||
|
||
static void scan_books(void)
|
||
{
|
||
const char *dir = READER_SD_ROOT "/Books";
|
||
g_nbooks = 0;
|
||
DIR *dp = opendir(dir);
|
||
if (!dp) {
|
||
snprintf(g_notice, sizeof g_notice, "未找到 %s 目录,请放入书籍(TXT)", dir);
|
||
g_notice_ms = 4000;
|
||
return;
|
||
}
|
||
struct dirent *de;
|
||
while ((de = readdir(dp)) && g_nbooks < 256) {
|
||
if (de->d_name[0] == '.') continue;
|
||
int f = fmt_from_ext(de->d_name);
|
||
if (f < 0) continue;
|
||
shelf_book *b = &g_books[g_nbooks];
|
||
if (snprintf(b->path, sizeof b->path, "%s/%s", dir, de->d_name) >= (int)sizeof b->path) continue;
|
||
strncpy(b->title, de->d_name, sizeof b->title - 1); b->title[sizeof b->title - 1] = 0;
|
||
char *dot = strrchr(b->title, '.');
|
||
if (dot) *dot = 0; /* 标题去扩展名 */
|
||
b->fmt = f;
|
||
struct stat st;
|
||
b->size = (stat(b->path, &st) == 0) ? (long)st.st_size : 0;
|
||
rdprog p;
|
||
b->ts = (rdprog_load(b->path, &p) == 1) ? p.ts : 0;
|
||
char sz[24]; fmt_size(sz, sizeof sz, b->size);
|
||
snprintf(b->sub, sizeof b->sub, "%s · %s%s", fmt_name(f), sz,
|
||
b->ts ? " · 读" : "");
|
||
g_nbooks++;
|
||
}
|
||
closedir(dp);
|
||
qsort(g_books, (size_t)g_nbooks, sizeof(shelf_book), cmp_recent_then_name);
|
||
}
|
||
|
||
/* 排队段头(按格式):为 list 模式生成 [段头(fmt) | 书] 序列。 */
|
||
static void build_rows(void)
|
||
{
|
||
g_nrows = 0;
|
||
static const int fmts[] = { 0, 1, 2, 3, 4 };
|
||
for (size_t fi = 0; fi < sizeof fmts / sizeof fmts[0]; fi++) {
|
||
int fmt = fmts[fi];
|
||
int have = 0;
|
||
if (g_nrows >= SHELF_MAX_ROWS) break;
|
||
for (int i = 0; i < g_nbooks; i++) {
|
||
if (g_books[i].fmt != fmt) continue;
|
||
if (!have) {
|
||
if (g_nrows >= SHELF_MAX_ROWS) break;
|
||
g_rows[g_nrows].section = 1; g_rows[g_nrows].fmt = fmt; g_rows[g_nrows].book = -1;
|
||
g_nrows++;
|
||
have = 1;
|
||
}
|
||
if (g_nrows >= SHELF_MAX_ROWS) break;
|
||
g_rows[g_nrows].section = 0; g_rows[g_nrows].fmt = fmt; g_rows[g_nrows].book = i;
|
||
g_nrows++;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ---- 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 = 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_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) 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) 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);
|
||
meow_listview_set_container(g_list, 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=设置。布局切换动作标签 = 将切换到的布局。 */
|
||
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, "刷新"); /* 1 刷新 */
|
||
meow_topappbar_add_action(g_appbar, "设置"); /* 2 设置 */
|
||
meow_topappbar_set_item_icon(g_appbar, 1, MEOW_ICON_REFRESH);
|
||
meow_topappbar_set_item_icon(g_appbar, 2, 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;
|
||
scan_books(); /* 回到书架即重扫(新增/最近读置顶刷新) */
|
||
build_rows();
|
||
const meow_theme *th = meow_theme_get();
|
||
g_bg = th->bg; g_fg = th->fg; g_dim = th->dim;
|
||
|
||
/* 布局:读 reader.conf ui.shelf_layout(A) */
|
||
const rdconf *c = rdconf_get();
|
||
g_layout = (c && c->shelf[0] && strcmp(c->shelf, "grid") == 0) ? 1 : 0;
|
||
|
||
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; }
|
||
|
||
/* 切换 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();
|
||
}
|
||
}
|
||
|
||
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]; 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) {
|
||
meow_ui_fill_rect(0, 620, 480, 44, g_bg);
|
||
meow_ui_text(20, 630, g_notice, 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) { /* 刷新:重扫 */
|
||
scan_books(); build_rows();
|
||
shelf_build_content();
|
||
shelf_redraw();
|
||
return 1;
|
||
} else if (bi == 2) { /* 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(); }
|
||
}
|