Files
MeowOS-SDK/tuku/ui/reader/reader.c
T
AskaEth e896a28fba 图库: 修 M31 评审 P1(两档共存峰值 + 换清晰挤掉预取)与三个 P2(r2)
P1-1 内存账没算全:高档解码时低档还占着槽,但门控/预留只按新图算。
- 高档请求携带发起时的常驻快照(page_req.resident),解码前按
  「常驻 + reader_estimate_peak()」算这次操作的总峰值,上限 MR_TIER_TOTAL_CAP
  (12MB,与 page_source 的绝对上限同值;常态 3MB 只约束 stb 回退路径,
  后端页本来就超 3MB 且已被 M27 放行),超了跳过高档、保留低档(skip=coexist)。
- 峰值本身靠调度压回基线:分页模式焦点页还是低档时不预取邻居(预取来会被
  换清晰腾掉),换清晰前 slots_reserve 腾掉非可见页,高档只与焦点低档共存。
  B8(--wrap malloc,两实例并行):max_live_delta 3250KB→550KB,
  绝对峰值 5279KB→3932KB(基线 3929KB)。
P1-2 换清晰挤掉预取:换清晰后不重跑窗口,邻居洞留到下次翻页(0/8 命中、4 次/页)。
- 新增每页 in-flight 去重(g_req_inflight):请求被 worker 取走后不再被窗口刷新重排,
  这是重复解码/挤掉邻居的根因之一。
- 高档落位后按最近一次翻页方向补回邻居(sharpen_refill);反方向不补(3MB 常驻
  上限下放不下,必然再被挤掉)。
- 真机几何探针(480×720,1280×2275 JPEG,8 次 700ms/页):0/8→8/8 命中,
  4→1.5 次解码/页。B8 顺序 100 页:100/100 命中、149 次解码(1.49/页)。

P2-1:B8 改成 101 页册顺序前翻 100 次(每页都是不同的下一页),4MP/12MP 夹具
真正成为焦点页;断言最后一页=100、命中率 ≥90%、解码 ≤2/页+余量。
P2-2:B10 去掉恒真的 slot_kb 比较,改断言「总常驻不增加」+「高档操作增量 ≤3MB」+
「两档共存绝对峰值有界」;补双击放大后换清晰不重置缩放/平移、条漫滚动位置不变。
P2-3:B10 整串生命周期解码次数 ≤12(实测 7);B8 解码次数上限。
夹具用硬链接(同文件系统),避免多页册真拷贝几百 MB。

顺带:MR_SHARPEN_MS 200→150(任务允许 150–300ms,给「换清晰→补邻居」留翻页余量);
新增宿主观察口 flip_hits/misses、zoom/pan/woff(只读)。
2026-11-06 16:15:50 +08:00

2935 lines
120 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ui/reader/reader.c — 阅读器页(ACT_READER,全屏):左右翻页 + 条漫连续滚动。
*
* 用户口径:优先流畅度,其次功能。两条阅读路径都不在翻页/滚动热路径上解码:
* - 后台 pthread 解码窗口(视口 ±1 页,总驻留 ≤ MR_SLOTS 张 downscale 后页图),
* 主循环只取已备好的图;
* - 翻页:跟手拖动只 blit 两张已缓存页图,松手后 meow_anim 一次短滑动吸附(150ms);
* - 条漫:meow_scroll 跟手 track + 松手 fling 惯性,按滚动位置懒解码视口 ±1 页;
* - 只 flush 受影响区(本页/整条可见区);解码/驻留耗时都 meow_log 打点。
*
* 页源:logic/reader/page_source 的 mr_book 契约(count/meta/open/close)。条漫排版用
* meta() 不解码像素;解码入口走 SDK M12 预算解码 meow_img_load_file_dim(JPEG 解码期缩解,
* PNG/BMP 整幅解码后降采样——超大头先 meow_img_probe_file 预判,超过预算跳过并告警)。
*
* 性能(M17):解码目标 = **排版真正要的显示框**(翻页=fit/zoom;条漫=strip 排版高度,
* 宽恒为视口宽),不是屏长边,所以翻页/滚动热路径只有原生 blit:不做 meow_ui_img_scaled
* 的每帧重采样,也不再每帧 malloc 1MB 级临时缓冲。几何变化(fit/缩放/切模式)时按新几何
* 重解一次;拖动/吸附帧只 invalidate 内容区(顶栏静态不重绘)。
* 内存(M17 起;M22 按真机实测重定;M27 接真缩解后端):单页显示缓存 ≤3MB、整窗常驻
* ≤3MB(3 槽,超限淘汰最远页)。解码路径与峰值预算由 page_source 的 reader_estimate_peak()
* 统一判定(唯一实现,本文件不再另写模型):
* - 真缩解后端(M23)能接住的页:峰值只与目标尺寸相关(≈1.3–2.5MB,与源分辨率无关),
* 直接放行,不做内存门控;
* - 后端拒绝的页(CMYK/算术编码 JPEG、Adam7 PNG、RLE/1–4bpp BMP、N≥8 小图…)回退 stb:
* 按 stb 真实分配模型算峰值,超 3MB 占位 / MemAvailable <6MB 占位。
* 占位可重试,不是永久失败。余量确实充足(MemFree 富余)时允许放宽到 12MB。
* 页数增长不会线性吃内存(常驻只与解码窗大小有关)。
* 手感(M31):JPEG 大页(≥1MP)先按「显示长边一半」的档位解一版低档并立刻上屏,停稳
* MR_SHARPEN_MS 后才解目标档位、在同一槽原地替换(两档都落满显示框 → 换图仍是单次 blit,
* 不闪白、不重置缩放/滚动)。拖动/连翻/条漫滚动/跳页在途期间只解低档;高档请求复用 M22 的
* gen/窗口取消通道作废。每页解码日志尾部带 tier=low|high 便于真机核对「先糊后清」。
* 方向:一律读 M3 的 mr_reader_get_direction()(默认 RTL),不另立全局。
*
* 模式(M7):翻页/条漫是一等公民。阅读 chrome 的图标按钮一键切换;每册的模式与 last page
* 经 meow_config 持久化(键 reader.mode/page.<册标识>,见 logic/reader.c),下次打开同一册
* 自动恢复。书库列表是文件管理器模型(M18),不再显示续读角标。
*
* 跳页:chrome 内 meow_slider 选目标页,松手后先把目标页解码进缓存再落位(不白屏不闪烁;
* 目标页的低档落位即可跳过去,停稳后原地换清晰;2.5s 超时则降级落位并提示)。
*
* 册间衔接:末页前进 → 同父目录下一个含图册(书库自然序)的首页;首页后退 → 上一册末页;
* 无相邻册时停住并轻提示(不误触退出)。条漫在条顶/条底继续拉 MR_BOOK_PULL 像素松手触发。
*
* 手势(与 SDK 语义对齐,见 sdk/meow_ui_input.c touch_release):
* - 左缘(x<24)向右划 = 系统返回(MEOW_SWIPE_LEFT)→ 不消费,app.c page_pop 退出;
* - 屏底 28px 上滑 = 系统回桌面(MEOW_EV_HOME),SDK 不分发给 App,本页不起手;
* - 翻页模式:中部横拖 = 跟手翻页;tap 分区方向感知(左/右 1/3 = 上/下一页,中 1/3 =
* chrome 显隐);双击 = 放大。
* - 条漫模式:竖向拖动 = 整条滚动(fling 惯性);tap = chrome 显隐;双击/横向滑动不动作。
*
* 节拍:app.c 的 on_tick 只转给 library_on_tick(不在本页 scope)。本页用一条常驻
* meow_anim “ticker”(SDK 主循环每轮 meow_anim_tick)驱动结果收取、跳页落位、续读节流
* 保存与单击判定超时;翻页动画与 meow_scroll fling 也走同一节拍。
*
* 生命周期:on_enter 建状态 + 建 TopAppBar/滑块 + 打开册并恢复模式/页码;on_leave 先落盘
* 续读状态,再全量释放(停解码线程、释放页图/册索引/条漫排版/滚动会话/控件)。
*/
#include "tuku_internal.h"
#include "ui/reader/reader.h"
#include "logic/reader/page_source.h"
#include "logic/reader/page_cache.h"
#include "logic/reader/nav.h"
#include <errno.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MR_SLOTS 3 /* 页图缓存槽上限(M22:4→3 省 ~1.4MB;翻页用 2,条漫视口 2–3) */
#define MR_TOP_BAR_H 56
#define MR_SLIDER_H 40
#define MR_SLIDE_MS 150 /* 松手后一次短滑动吸附 */
#define MR_BOUNCE_MS 120 /* 未过阈值回弹 */
#define MR_DOUBLE_MS 240 /* 单击等待窗口(区分双击) */
#define MR_ZOOM_PCT 200 /* 双击放大倍率 */
#define MR_TICK_MS 16
#define MR_PAINT_MS 16 /* 跟手拖动重绘节流(≈60fps 上限) */
#define MR_DRAG_SLOP 8 /* 小于此位移算点击 */
#define MR_ZOOM_TEMP_CAP (6 * 1024 * 1024) /* 缩放临时缓冲软上限(>此值降倍率,保内存) */
#define MR_BOOK_PULL 110 /* 条漫到顶/底后再拉这么远,松手进相邻册 */
#define MR_JUMP_TIMEOUT_MS 2500 /* 目标页迟迟解不出来 → 降级落位并提示 */
#define MR_CONT_SAVE_MS 1000 /* 续读状态落盘节流 */
#define MR_TOAST_MS 1400
/* ---- 内存预算(M22 校准;M27 起模型与门控只在 page_source.c 的 reader_estimate_peak 里)----
* 真机参考:MemTotal 56872kB、空闲 RSS 6044kB、杀点 RSS≈15.6MB / MemFree 仅 1.4MB。 */
#define MR_PREFETCH_MIN_KB (12L * 1024) /* 预取邻居要求的最低 MemAvailable */
#define MR_SKIP_RETRY_MS 1500 /* 被预算跳过的页:焦点页冷却这么久后自动重试 */
#define MR_PAGE_BYTES_CAP (3L * 1024 * 1024) /* 单页「显示尺寸」缓存字节上限 */
#define MR_RESIDENT_CAP (3L * 1024 * 1024) /* 页图缓存总字节上限(M22 由 9MB 校准为 3MB) */
#define MR_SLOT_TOL 2 /* 缓存图与显示框允许的像素偏差(≤2 走原生 blit,不重采样) */
/* ---- 两级清晰度(M31):先低档秒出,停稳再换高档 ----
* 大页(真机 1280×2275 单页解码 392ms)打开/翻到时先按「显示长边的一半」解一版低档,
* 落框后立刻上屏;停稳 MR_SHARPEN_MS 后再解目标档位替换同一槽。低档与高档都**精确落到
* 显示框**,所以换图那一下尺寸不变:仍是一次原生 blit,不闪白、不重置缩放/滚动。
* 内存(r2,评审 P1-1):高档解码时低档还占着槽,所以「一次操作的总峰值」=
* resident_bytes()(至少含焦点槽低档)+ reader_estimate_peak()(本次解码的峰值);
* 该总峰值受 MR_TIER_TOTAL_CAP 约束(与 page_source 的 12MB 绝对上限同值——常态 3MB
* 只约束 stb 回退路径,后端页连 3MB 都超却已被 M27 放行,拿 3MB 当两档共存上限会让
* 「先糊后清」在最需要的大页上永远不触发)。超限 → 跳过高档、保留低档(skip=coexist)。
* 另两条纪律把常驻压到最低(评审 P1-2):分页模式焦点页还是低档时不预取邻居(停稳 150ms 后
* 就会换清晰,预取来也会被挤掉),高档落位后再按翻页方向补回邻居。 */
#define MR_SHARPEN_MS 150 /* 停稳多久后才发起高档解码(任务允许 150–300ms;取 150 给「换清晰→补邻居」留出翻页余量) */
#define MR_TIER_MIN_PIXELS 1000000LL /* 源像素少于此值不分档:解本身不慢,分档只添乱 */
#define MR_LOW_MIN_LONG 240 /* 低档输出长边下限(再粗就糊到难看) */
#define MR_LOW_MAX_LONG 480 /* 低档输出长边上限(再细省不了多少解码) */
#define MR_TIER_TOTAL_CAP (12LL * 1024 * 1024) /* 两档共存:常驻 + 单次解码 的绝对上限 */
/* MR_TIER_LOW / MR_TIER_HIGH 定义在 ui/reader/reader.h(宿主测试也要用同一套档位值)。 */
#define MR_REQ_MAX 8
#define MR_RES_MAX 2 /* 结果队列上限(M22:4→2;未取走的已解码图不计入常驻预算) */
/* view on_event 的事件号不在公共头(sdk/meow_ui_internal.h §6.4);本页只用到 SWIPE=3。 */
#define MR_EV_SWIPE 3
static mr_page_ctrl g_ctrl;
static char g_dir[MR_PATH_MAX];
static int g_start_at = MR_READER_START_RESUME; /* 下次 on_enter 的显式起始页(-1=续读) */
/* 下次 on_enter 的「按文件名定位起始页」(书库点图;只对目录册生效,open_book 消费后清空)。
* 用文件名而不是下标:书库列表可能被 MR_LIB_SCAN_MAX 截断,下标不可信。 */
static char g_start_name[MR_NAME_MAX];
/* 浏览根(生产=MR_ROOT_DIR,宿主测试可改):册间衔接不得越过它。 */
static char g_root[MR_PATH_MAX] = MR_ROOT_DIR;
static mr_book_handle g_handle; /* 目录册或档案册(M11 统一开册) */
static mr_book g_book;
static int g_count; /* 0=空册/不可读 */
static int g_open_err;
static uint64_t g_open_retry_at; /* 资源类打开失败(ENOMEM/EMFILE…)的自动重试时刻 */
static int g_open_retry_used; /* 1=本次进入已用过自动重试(避免反复重试) */
static int g_page; /* 翻页模式当前页;条漫模式=最近落位页 */
static int g_mode = MR_MODE_PAGED;
static int g_vw, g_vh; /* 内容视口 = 全屏 */
static int g_fit = MR_FIT_WIDTH;
static int g_zoom_pct = 100;
static int g_pan_x, g_pan_y;
static int g_chrome = 1;
static meow_widget *g_tb;
static int g_act_mode = -1; /* TopAppBar 动作下标:模式切换 */
static int g_act_jump = -1; /* 动作下标:跳页滑块 */
static int g_act_fit = -1; /* 动作下标:fit 档循环 */
/* ---- 条漫:排版表 + 滚动会话 ---- */
static mr_strip g_strip;
static int g_strip_ok;
static int g_woff; /* 条漫滚动偏移(px) */
static meow_scroll *g_scroll;
/* ---- 跳页(chrome 滑块 + 预解码落位) ---- */
static meow_widget *g_slider;
static int g_jump_open;
static mr_jump g_jump;
/* ---- 轻提示(册边界/模式切换/跳页) ---- */
static char g_toast[64];
static uint64_t g_toast_until;
static int g_title_page = -1; /* 标题里显示的页(条漫滚动停止后同步) */
/* ---- 续读落盘(节流) ---- */
static int g_cont_dirty;
static int g_cont_last_page = -1;
static int g_cont_last_mode = -1;
static uint64_t g_cont_save_ms;
/* ---- 当前解码窗(≤MR_SLOTS 页,按优先级排列) ---- */
static int g_win_idx[MR_SLOTS];
static int g_win_n;
static int g_fail_paint; /* 窗口内出现解码失败页 → tick 重绘错误占位 */
/* ---- 页图缓存(≤4 张 downscale 后的图)---- */
typedef struct page_slot {
int idx; /* -1=空 */
meow_img *img; /* 已 downscale 的页图 */
int src_w, src_h;
int want_w, want_h; /* 本次解码按哪个显示框落的(几何判据,见 slot_ready) */
unsigned char tier; /* MR_TIER_*:低档过渡图 / 目标档位(M31) */
unsigned char hi_tried; /* 1=这一槽最近一次「换清晰」失败/跳过(别每 tick 重试;
* 拖动取消时由 settle_reset 复位,停稳后还能再试) */
} page_slot;
static page_slot g_slot[MR_SLOTS];
/* 解码失败页标记:坏图/无法解析 → 永久显示占位(避免无限重试)。
* 与「预算跳过」是两回事,见 g_page_skip:后者可重试。 */
static unsigned char g_page_fail[(MR_BOOK_MAX_PAGES + 7) / 8];
/* 预算跳过页标记(M22):内存不足 / 单页峰值超预算 → 先不解码,显示「内存不足」占位。
* 必须在条件改善后可重试:离开再回来(焦点页变化)、或用户点按显式重试、或冷却期后自动重试。
* M17 的实现把这类页直接标成永久 failed,用户再也看不到——那等于把内存问题变成功能问题。 */
static unsigned char g_page_skip[(MR_BOOK_MAX_PAGES + 7) / 8];
static uint64_t g_skip_ms; /* 最近一次预算跳过的时刻(自动重试节流) */
/* 每页原始像素尺寸缓存(打开时按 count 分配;0,0=未知)。绘制/排版几何只查这里,
* 不在每帧去 probe 文件(dir_ops_meta 是 fopen+读头,每帧一次会直接拖垮翻页)。 */
typedef struct page_meta { int w, h; } page_meta;
static page_meta *g_meta;
/* 本次解码计划(主线程在 queue_page 里按显示框算好,经 page_req 传给 worker;worker 在
* mr_book_open 前写入这些静态量,解码回调只读它们 → 后台线程不再读 UI 全局几何)。
* want_* = 显示框(几何,slot_ready 的判据);box_* = 字节上限钳制后的落地尺寸
* (解码结果会精确重采样到 box_*,所以命中页仍是 1 次 blit)。 */
typedef struct decode_plan {
int want_w, want_h;
int box_w, box_h;
int tier; /* MR_TIER_LOW / MR_TIER_HIGH(M31) */
int sharpen; /* 1=「停稳换清晰」的追加请求(要算两档共存峰值) */
long long resident; /* 发起这次请求时已在槽里的字节(主线程快照) */
} decode_plan;
static decode_plan g_plan;
static int g_plan_page; /* 本次解码的页号(只用于日志) */
static int g_decode_skip; /* reader_load_page 的跳过原因(MR_SKIP_*) */
static int g_decode_tier; /* reader_load_page 实际生效的档位(低档降为单档时=HIGH) */
static volatile int g_decode_count; /* 累计解码次数(观察重解抖动/活锁) */
#define MR_SKIP_NONE 0 /* 解出来了 */
#define MR_SKIP_LOWMEM 1 /* 内存门控拦下(MemAvailable 不足) */
#define MR_SKIP_PEAK 2 /* 真实峰值超预算,且没有余量放行 */
#define MR_SKIP_COEXIST 3 /* 两档共存总峰值超上限:跳过高档、保留低档 */
static int page_failed(int idx)
{
if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return 0;
return (g_page_fail[idx >> 3] >> (idx & 7)) & 1;
}
static void page_mark_failed(int idx)
{
if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return;
g_page_fail[idx >> 3] |= (unsigned char)(1u << (idx & 7));
}
static int page_skipped(int idx)
{
if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return 0;
return (g_page_skip[idx >> 3] >> (idx & 7)) & 1;
}
static void page_mark_skipped(int idx)
{
if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return;
g_page_skip[idx >> 3] |= (unsigned char)(1u << (idx & 7));
g_skip_ms = meow_ui_time_ms();
}
static void page_skip_clear(int idx)
{
if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return;
g_page_skip[idx >> 3] &= (unsigned char)~(1u << (idx & 7));
}
/* 该页现在没图、且不会自己再来(永久失败 / 被预算跳过)→ 画占位文字而不是「加载中…」。
* 两者都是「没图」,但只有 skip 是可重试的(见 maybe_retry_skipped_focus)。 */
static int page_blocked(int idx)
{
return page_failed(idx) || page_skipped(idx);
}
/* 占位文字:坏图是不可恢复的;预算跳过是「内存不足」,点按可重试(任务 2 的可重试语义)。 */
static const char *page_placeholder(int idx)
{
if (page_skipped(idx)) return "内存不足,点按重试";
if (page_failed(idx)) return "图片无法显示";
return "加载中…";
}
/* ---- 后台解码队列(请求携带解码几何,结果带回同一条几何) ---- */
typedef struct page_req {
int idx;
unsigned gen;
int want_w, want_h; /* 按哪个显示框解码(主线程算,slot_ready 判据) */
int box_w, box_h; /* 落地上限(单页字节上限钳制后的目标尺寸) */
unsigned char tier; /* MR_TIER_*(M31:低档过渡 / 目标档位) */
unsigned char sharpen; /* 1=「停稳后换清晰」的追加请求:动起来就作废 */
long long resident; /* 发起时的常驻字节(M31 r2:高档要算两档共存峰值) */
} page_req;
typedef struct page_res {
int idx;
unsigned gen;
meow_img *img;
int fail;
int skip; /* MR_SKIP_*:非 0 = 预算跳过(可重试,不是坏图) */
int want_w, want_h;
unsigned char tier; /* MR_TIER_*:结果按哪一档解出来的 */
unsigned char sharpen; /* 1=这是一次「停稳换清晰」的结果(落位后要补邻居) */
} page_res;
static pthread_t g_worker;
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t g_cv = PTHREAD_COND_INITIALIZER;
static int g_worker_on, g_stop;
static unsigned g_gen = 1;
static page_req g_req[MR_REQ_MAX];
static int g_req_head, g_req_tail, g_req_n;
static page_res g_res[MR_RES_MAX];
static int g_res_n;
/* ---- 两级清晰度状态(M31) ----
* g_sharpen_idx:当前唯一允许解高档的页(-1=拖动/未停稳/无需升级)。worker 在锁内据此
* 作废「在飞的高档请求」——复用 M22 已有的 gen/窗口取消机制,只是多一条判据。
* g_move_ms:最近一次「动」的时刻(按下/拖动/吸附动画/条漫滚动/fling/换页);
* 停稳 MR_SHARPEN_MS 后才允许升级。宿主测试可用 mr_reader_ui_test_set_sharpen_ms 改写窗口。
* g_step_dir:最近一次翻页/跳页的方向(+1 页号增,-1 页号减);高档落位后按这个方向优先
* 补邻居(P1-2:不在反方向白解一张马上会被挤掉的图)。
* g_refill:高档落位 → tick 里补回邻居(换清晰会为省峰值腾掉邻居,不补就每次翻页都 miss)。 */
static int g_sharpen_idx = -1;
/* 每页是否有请求已排队/正在 worker 手里(去重)。M31 r2(评审 P1-2):只靠队列扫描不够——
* 请求被 worker 取走后就不在队列里了,窗口刷新会再排一次同一页(重复解码 + 挤掉刚预取的
* 邻居,正是「每翻一页解 4 次 / 0 命中」的来源)。锁内读写。 */
static unsigned char g_req_inflight[MR_BOOK_MAX_PAGES];
static uint64_t g_move_ms;
static int g_sharpen_ms = MR_SHARPEN_MS;
static int g_step_dir = 1;
static int g_refill;
static volatile int g_low_decodes; /* 完成的低档解码次数(宿主测试/日志对照) */
static volatile int g_high_decodes; /* 完成的目标档位解码次数 */
static volatile int g_high_canceled; /* 被取消的高档请求数(拖动/换页/停稳失效) */
static volatile int g_flip_hits; /* 翻页时目标页已在槽(预取命中)次数 */
static volatile int g_flip_misses; /* 翻页时目标页还没解好(要等解码)次数 */
/* ---- 节拍/动画 ---- */
static meow_anim *g_ticker;
static meow_anim *g_snap; /* 翻页滑动吸附 */
static meow_anim *g_bounce; /* 未过阈值回弹 */
static int g_slide_delta; /* 翻页目标增量(动画中) */
static int g_slide_from; /* 进场页起点(px) */
static int g_anim_v; /* 进场页当前位移(px) */
static uint64_t g_flip_t0;
static uint64_t g_last_tick;
static uint64_t g_last_paint;
static int g_quitting; /* on_leave 期间抑制回调 */
/* ---- 跟手拖动 ---- */
static int g_dragging; /* 1=本次按下正在拖动 */
static int g_drag_mode; /* 0=无 1=横滑翻页 2=放大平移 3=滑块 4=条漫滚动 */
static int g_drag_x0, g_drag_y0;
static int g_drag_off; /* 当前页水平位移(px) */
static int g_drag_pan_x0, g_drag_pan_y0;
static int g_touch_moved; /* 本次按下是否已产生 >slop 的位移 */
static int g_wo_last_y; /* 条漫:上一次 move 的 y */
static int g_wo_y0; /* 条漫:按下点 y(slop 判定) */
static int g_wo_pull; /* 条漫:到边后继续拉的累计量(>0 往末页方向) */
/* ---- 绘页路径计数(宿主测试观察:翻页热路径应全部是 blit)---- */
#ifdef MR_READER_HOST_TEST
static int g_blit_draws;
static int g_scaled_draws;
#define NOTE_BLIT() (g_blit_draws++)
#define NOTE_SCALED() (g_scaled_draws++)
#else
#define NOTE_BLIT() ((void)0)
#define NOTE_SCALED() ((void)0)
#endif
/* ---- 单击延迟(双击判定窗口)---- */
static int g_tap_pending;
static int g_tap_x, g_tap_y;
static uint64_t g_tap_ms;
static uint64_t g_last_tap_ms; /* 上一次点击(跨按下保持,双击判定用) */
static int g_last_tap_x, g_last_tap_y;
/* ---- 前向声明(互相调用:册切换/模式/落位) ---- */
static int open_book(const char *dir, int start_page, int mode);
static int book_switch(int delta);
static void mode_set(int mode, int from_page);
static void toast(const char *msg);
static void slider_place(void);
static void update_title(void);
static void invalidate_content(void);
static page_slot *slot_find(int idx);
static void cont_touch(void);
static void cont_save_now(void);
static int current_page(void);
static int page_needs_tier(int idx);
/* ============================================================ */
/* 内存读数与观测(M22) */
/* ------------------------------------------------------------
* /proc/meminfo 与 /proc/self/status 是唯一可用的实时内存事实来源。读不到(宿主/非 Linux)
* 一律返回 -1,调用方必须按「未知」处理,不许猜。宿主测试用 mr_reader_ui_test_set_mem()
* 注入固定读数,让内存门控/放宽档可以确定性验证。
* ============================================================ */
static long g_mem_free_override = -1; /* >=0 = 宿主测试注入的 MemFree(kB) */
static long g_mem_avail_override = -1; /* >=0 = 宿主测试注入的 MemAvailable(kB) */
static long mem_kb(const char *path, const char *key)
{
FILE *f = fopen(path, "r");
if (!f) return -1;
char line[128];
long v = -1;
size_t kl = strlen(key);
while (fgets(line, sizeof line, f)) {
if (strncmp(line, key, kl) == 0) { v = strtol(line + kl, NULL, 10); break; }
}
fclose(f);
return v;
}
/* MemFree(kB)。真机杀点就是「MemFree 见底」:15,600kB 那次只剩 1,404kB。 */
static long mem_free_kb(void)
{
if (g_mem_free_override >= 0) return g_mem_free_override;
return mem_kb("/proc/meminfo", "MemFree:");
}
/* MemAvailable(kB):含可回收页缓存。注意本机在 MemFree 1.4MB 时仍有 ~26MB,
* 所以它只能当「系统极度缺页」的门控,不能当解码配额(内核不会等回收就杀进程)。 */
static long mem_avail_kb(void)
{
if (g_mem_avail_override >= 0) return g_mem_avail_override;
return mem_kb("/proc/meminfo", "MemAvailable:");
}
static int resident_pages(void)
{
int n = 0;
for (int i = 0; i < MR_SLOTS; i++)
if (g_slot[i].img) n++;
return n;
}
static long resident_bytes(void)
{
long b = 0;
for (int i = 0; i < MR_SLOTS; i++)
if (g_slot[i].img) b += (long)g_slot[i].src_w * g_slot[i].src_h * 4;
return b;
}
/* 进出阅读器各打一行(任务 5):RSS 看当前占用,VmHWM 看本次进入的峰值——
* 验收就是「离开时的 hwm 不超过预算」。 */
static void log_mem(const char *tag)
{
meow_log(MEOW_LOG_INFO, "reader mem[%s]: rss=%ldkB hwm=%ldkB memfree=%ldkB memavail=%ldkB pages=%d img=%ldkB",
tag, mem_kb("/proc/self/status", "VmRSS:"),
mem_kb("/proc/self/status", "VmHWM:"), mem_free_kb(), mem_avail_kb(),
resident_pages(), resident_bytes() / 1024);
}
/* ============================================================ */
/* 页源解码回调(注入 logic 层的 mr_book 契约;可在后台线程调用) */
/* ============================================================ */
/* 由显示框(want_w×want_h)反推解码长边:默认就解到显示框大小(解码后可直接 blit),
* 只有单页缓存字节上限(MR_PAGE_BYTES_CAP)是硬上界——超长条漫在这里按比例收缩。
* 不再钳到屏长边:条漫页常见长宽比 1.6~1.8,显示高度本来就 > 屏长边,钳了必然每帧重采样。 */
static int plan_decode_long(int want_w, int want_h)
{
int l0 = want_w > want_h ? want_w : want_h;
int s0 = want_w > want_h ? want_h : want_w;
if (l0 <= 0) return 0;
int l = l0 < 64 ? 64 : l0;
while (l > 64) {
int s = (int)((long long)s0 * l / l0);
if ((long long)l * s * 4 <= MR_PAGE_BYTES_CAP) break;
l = l * 3 / 4;
}
return l;
}
/* 显示框 → 实际落地尺寸(保持长宽比,受单页字节上限钳制)。
* 正常情况下 box == want(解码结果就是显示尺寸 → 单次 blit);只有超大屏/超长条漫
* 才会收缩,此时绘制走缩放路径(M17 已知代价,不再是常态)。 */
static void plan_box_size(int want_w, int want_h, int *bw, int *bh)
{
int w = want_w > 0 ? want_w : g_vw;
int h = want_h > 0 ? want_h : g_vh;
if (w <= 0 || h <= 0) { w = h = 1; }
int long0 = w > h ? w : h;
int l = plan_decode_long(w, h);
if (l > 0 && l < long0) {
if (w >= h) { h = (int)((long long)h * l / w); w = l; }
else { w = (int)((long long)w * l / h); h = l; }
if (w < 1) w = 1;
if (h < 1) h = 1;
}
*bw = w;
*bh = h;
}
/* 固定字段的解码日志:page/src/peak/path 之后是结论,便于 `grep 'reader decode'`
* 后按列看曲线。peak = reader_estimate_peak 的预估峰值(字节/kB);path = 实际解码路径
* (backend = M23 真缩解后端,stb = 回退旧路径);tier = 本次请求的清晰度档位(M31)。 */
static void log_decode(int page, int src_w, int src_h, long long peak_bytes,
long memfree, long memavail, const char *verdict, int step,
int backend, int tier, unsigned long long ms)
{
meow_log(MEOW_LOG_INFO,
"reader decode page=%d src=%dx%d peak=%lldkB memfree=%ldkB memavail=%ldkB %s path=%s step=%d ms=%llu tier=%s",
page, src_w, src_h, peak_bytes / 1024, memfree, memavail, verdict,
backend ? "backend" : "stb", step, ms,
tier == MR_TIER_LOW ? "low" : "high");
}
/* 低档解码目标长边(M31):显示长边的一半,夹到 [MR_LOW_MIN_LONG, MR_LOW_MAX_LONG]。
* 只对 JPEG 分档,且必须真的下探一档以上(后端 DCT 阶 n = ceil(8×目标/源长边);
* 低档 n 不更小 = 同样的活,只多一次落框重采样):
* - 「档位」(step)本来就是 JPEG 缩解的概念;PNG/BMP 后端按目标逐行丢行,本来就不慢,
* 而且阅读器的单页峰值模型对非 JPEG 按「输出=显示框」建模——再加一条小图→落框的
* 路径会让模型低估。分档只留给真机 168–400ms 的 JPEG 大页。
* 返回 0 = 这页不分档,直接按目标档位解。 */
static int plan_low_dim(const mr_img_info *in, int box_w, int box_h)
{
if (!in || in->w <= 0 || in->h <= 0) return 0;
if (in->codec != MR_IMG_CODEC_JPEG) return 0;
if ((long long)in->w * in->h < MR_TIER_MIN_PIXELS) return 0;
int l0 = in->w > in->h ? in->w : in->h;
int bl = box_w > box_h ? box_w : box_h;
if (l0 <= 0 || bl <= 0) return 0;
int lo = bl / 2;
if (lo < MR_LOW_MIN_LONG) lo = MR_LOW_MIN_LONG;
if (lo > MR_LOW_MAX_LONG) lo = MR_LOW_MAX_LONG;
if (lo >= bl) return 0; /* 显示框本来就不大:没有更粗的余地 */
int nh = (int)((8LL * bl + l0 - 1) / l0);
int nl = (int)((8LL * lo + l0 - 1) / l0);
if (nh < 1) nh = 1;
if (nl < 1) nl = 1;
if (nl >= nh) return 0; /* 同一 DCT 档:低档并不更快 */
return lo;
}
/* 按「解码目标长边」跑一次 page_source 的唯一峰值模型(含超预算时继续加粗档位的旧逻辑)。
* JPEG 档位由目标长边反推,与 meow_img_load_file_step_ex 的 eff 口径一致;模型在
* 「step>1 且输出≠显示框」时会把落框临时图算进峰值——低档走的正是这条已校准的路径。 */
static void estimate_for(const mr_img_info *in, int load_dim, int box_w, int box_h,
long mfree, long mavail, mr_decode_decision *dec)
{
int step = 1;
if (in->codec == MR_IMG_CODEC_JPEG) {
int l0 = in->w > in->h ? in->w : in->h;
step = meow_img_pick_scale(l0, load_dim);
}
for (;;) {
reader_estimate_peak(in, step, box_w, box_h, mfree, mavail, dec);
/* 后端命中 / 已进预算 / 已到最粗档位:定案。非 JPEG 无档位可调。 */
if (dec->path == MR_DECODE_PATH_BACKEND || dec->skip == MR_DECODE_OK ||
step >= 8 || in->codec != MR_IMG_CODEC_JPEG)
break;
step *= 2;
}
}
static meow_img *reader_load_page(const char *path, void *ud, int maxdim)
{
(void)ud;
g_decode_skip = MR_SKIP_NONE;
uint64_t t0 = meow_ui_time_ms();
if (!path || !path[0]) return NULL;
/* 解码计划由 queue_page() 在主线程算好(显示框 + 落地上限 + 档位);worker/主线程
* 同步兜底路径在 mr_book_open 前写入。无计划时退回旧预算 maxdim。 */
int want_w = g_plan.want_w, want_h = g_plan.want_h;
int box_w = g_plan.box_w, box_h = g_plan.box_h;
int page = g_plan_page;
if (box_w <= 0 || box_h <= 0) {
if (maxdim > 0) {
box_w = box_h = maxdim;
} else {
box_w = want_w > 0 ? want_w : 1;
box_h = want_h > 0 ? want_h : 1;
}
}
int dim = box_w > box_h ? box_w : box_h;
long mfree = mem_free_kb();
long mavail = mem_avail_kb();
/* 1) 头部探测(零像素内存),再交给 page_source 的唯一模型定径:
* reader_estimate_peak 同时给出「走后端还是 stb」与「是否占位」。后端能接住的页
* (M23,峰值与源尺寸无关)直接放行;只有回退 stb 的页才参与峰值预算/MemAvailable
* 门控。模型不再在本文件另写一份。 */
int tier = g_plan.tier;
mr_img_info in;
int have = mr_probe_info(path, &in);
int load_dim = dim; /* 本次解码的输出长边(低档 < dim) */
mr_decode_decision dec;
memset(&dec, 0, sizeof dec);
dec.step = 1;
g_decode_tier = MR_TIER_HIGH;
if (!have) tier = MR_TIER_HIGH; /* 尺寸未知:没有分档依据,按单档解(日志别谎报) */
if (have) {
if (tier == MR_TIER_LOW) {
int lo = plan_low_dim(&in, box_w, box_h);
if (lo > 0 && lo < dim) load_dim = lo;
else tier = MR_TIER_HIGH; /* 这页不值得/无法再粗 → 单档 */
}
estimate_for(&in, load_dim, box_w, box_h, mfree, mavail, &dec);
/* 低档多一次落框重采样,峰值比高档高;门控拒绝时退回单档(高档),别把本来
* 能显示的页变成「内存不足」占位。非 JPEG 的 stb 回退没有缩解能力(整幅解后
* 再缩),低档只是白解一遍,同样退回单档。 */
if (tier == MR_TIER_LOW &&
(dec.skip != MR_DECODE_OK ||
(dec.path == MR_DECODE_PATH_STB && in.codec != MR_IMG_CODEC_JPEG))) {
tier = MR_TIER_HIGH;
load_dim = dim;
estimate_for(&in, load_dim, box_w, box_h, mfree, mavail, &dec);
}
g_decode_tier = tier; /* 实际生效的档位(worker 据此计数/落槽) */
/* 两档共存(M31 r2 / 评审 P1-1):高档解码时低档仍在槽里显示,所以这次操作的
* 真实峰值 = 已在槽里的常驻 + 本次解码峰值。M27 的模型只描述后者;这里把前者
* 加进同一个上限(MR_TIER_TOTAL_CAP),超了就跳过高档、保留低档(页面仍可读)。 */
if (g_plan.sharpen && dec.skip == MR_DECODE_OK &&
g_plan.resident + dec.peak_bytes > MR_TIER_TOTAL_CAP) {
g_decode_skip = MR_SKIP_COEXIST;
log_decode(page, in.w, in.h, dec.peak_bytes, mfree, mavail, "skip=coexist",
dec.step, dec.path == MR_DECODE_PATH_BACKEND, tier,
(unsigned long long)(meow_ui_time_ms() - t0));
return NULL;
}
if (dec.skip != MR_DECODE_OK) {
g_decode_skip = dec.skip == MR_DECODE_SKIP_LOWMEM ? MR_SKIP_LOWMEM : MR_SKIP_PEAK;
log_decode(page, in.w, in.h, dec.peak_bytes, mfree, mavail,
dec.skip == MR_DECODE_SKIP_LOWMEM ? "skip=lowmem" : "skip=peak",
dec.step, dec.path == MR_DECODE_PATH_BACKEND, tier,
(unsigned long long)(meow_ui_time_ms() - t0));
return NULL;
}
}
/* 2) 解码(路径/档位已定)。失败与「跳过」必须分开:NULL + skip==0 才是坏图。
* P1-1:门控按后端放行的页必须用 backend-only 入口——后端中途失败(截断/坏流/
* DHT 超限/malloc 失败)直接返回 NULL 按坏图占位,绝不静默回落 stb(实测会放出
* 18–36MB)。只有判定为 stb 的页才允许 stb。 */
int allow_stb = !have || dec.path != MR_DECODE_PATH_BACKEND;
int used_backend = 0;
meow_img *im = have ? meow_img_load_file_step_ex(path, load_dim, dec.step, allow_stb,
&used_backend)
: meow_img_load_file_dim(path, dim);
if (!im) {
log_decode(page, have ? in.w : 0, have ? in.h : 0, dec.peak_bytes, mfree, mavail, "fail",
dec.step, have && dec.path == MR_DECODE_PATH_BACKEND, tier,
(unsigned long long)(meow_ui_time_ms() - t0));
return NULL;
}
/* 兜底:非 JPEG 或粗档位输出没被缩到上限内,再降一次(保持驻留纪律)。 */
if (dim > 0 && (meow_img_w(im) > dim || meow_img_h(im) > dim)) {
meow_img *small = meow_img_downscale(im, dim);
if (small) { meow_img_free(im); im = small; }
}
/* 3) 精确落到显示框(低档过渡图 / 故意用了粗档位时):结果 = box → 命中页仍是一次原生
* blit,不会把「先糊后清」变成每帧重采样(M17 的热路径纪律)。低档也落满显示框,
* 所以换高档时尺寸不变:不闪白、不重置缩放/滚动,且两档在槽里是同一张的位置。
* 源图本身就小于显示框(小图/缩略图)时绝不放大:那会凭空多占常驻字节,
* 且 M17 的 slot_ready 语义依赖「SDK 不做上采样」。 */
if (have && (tier == MR_TIER_LOW || dec.step > 1) && (in.w >= box_w || in.h >= box_h) &&
(meow_img_w(im) != box_w || meow_img_h(im) != box_h)) {
meow_img *fit = meow_img_resample(im, box_w, box_h);
if (fit) { meow_img_free(im); im = fit; }
}
log_decode(page, have ? in.w : 0, have ? in.h : 0, dec.peak_bytes, mfree, mavail, "ok",
dec.step, used_backend, tier,
(unsigned long long)(meow_ui_time_ms() - t0));
return im;
}
static void reader_unload_page(meow_img *img)
{
meow_img_free(img);
}
/* ============================================================ */
/* 页原始尺寸缓存(绘制几何的唯一来源;每页只 probe 一次) */
/* ============================================================ */
static int page_meta_dims(int idx, int *w, int *h)
{
if (w) *w = 0;
if (h) *h = 0;
if (idx < 0 || idx >= g_count) return 0;
if (g_meta) {
if (g_meta[idx].w > 0 && g_meta[idx].h > 0) {
if (w) *w = g_meta[idx].w;
if (h) *h = g_meta[idx].h;
return 1;
}
if (g_meta[idx].w < 0) return 0; /* 探测过且未知:别再每帧 fopen 一遍 */
}
int sw = 0, sh = 0;
if (!mr_book_meta(&g_book, idx, &sw, &sh) || sw <= 0 || sh <= 0) {
if (g_meta) { g_meta[idx].w = -1; g_meta[idx].h = 0; }
return 0;
}
if (g_meta) { g_meta[idx].w = sw; g_meta[idx].h = sh; }
if (w) *w = sw;
if (h) *h = sh;
return 1;
}
/* 显示框(fit + zoom):绘图与点击几何都走这里。未知名按 2:3 竖图占位。 */
static void page_target_size_at(int idx, int *w, int *h)
{
int sw = 0, sh = 0;
if (w) *w = 0;
if (h) *h = 0;
if (!w || !h) return;
if (!page_meta_dims(idx, &sw, &sh)) {
/* meta 不可用(未知/坏头):用已解码尺寸兜底(保持真实长宽比),
* 都没解出来才按 2:3 竖图占位——排版稳定且不会把页面拉变形。 */
page_slot *s = slot_find(idx);
if (s) { sw = s->src_w; sh = s->src_h; }
if (sw <= 0 || sh <= 0) {
sw = g_vw > 0 ? g_vw : 1;
sh = sw * 3 / 2;
}
}
if (mr_nav_fit_size(g_fit, sw, sh, g_vw, g_vh, w, h) != 0) return;
if (g_zoom_pct != 100) mr_nav_zoom_size(*w, *h, g_zoom_pct, w, h);
}
/* 解码/绘制的目标显示框:条漫 = strip 排版几何(宽恒为视口宽、高由排版表定),
* 翻页 = fit/zoom。解码计划与「缓存是否就绪」必须用同一个函数:否则解出来的尺寸
* 和排版要的尺寸不同源,条漫长页(长宽比 > 屏高/屏宽)就每帧重采样(M17 评审 P1-1)。 */
static void page_geometry_at(int idx, int *w, int *h)
{
if (w) *w = 0;
if (h) *h = 0;
if (!w || !h) return;
if (g_mode == MR_MODE_WEBTOON && g_strip_ok) {
int ph = mr_strip_page_h(&g_strip, idx);
if (ph > 0) { *w = g_vw; *h = ph; return; }
}
page_target_size_at(idx, w, h);
}
/* ============================================================ */
/* 续读状态(逐册 last page + mode;写入节流,避免滚动时频繁写卡) */
/* ============================================================ */
static int current_page(void)
{
if (g_count <= 0) return 0;
if (g_mode == MR_MODE_WEBTOON && g_strip_ok)
return mr_strip_page_at(&g_strip, g_woff);
return g_page;
}
static void cont_save_now(void)
{
if (!g_dir[0] || g_count <= 0) { g_cont_dirty = 0; return; }
int p = current_page();
if (p == g_cont_last_page && g_mode == g_cont_last_mode) { g_cont_dirty = 0; return; }
mr_cont_save(g_dir, p, g_mode);
g_cont_last_page = p;
g_cont_last_mode = g_mode;
g_cont_dirty = 0;
meow_log(MEOW_LOG_INFO, "reader cont save page=%d/%d mode=%s",
p + 1, g_count, mr_mode_name(g_mode));
}
static void cont_touch(void)
{
g_cont_dirty = 1;
}
/* 只在滚动/翻页停下且距上次写盘超过 MR_CONT_SAVE_MS 时落盘。 */
static void cont_save_throttled(void)
{
if (!g_cont_dirty) return;
if (g_scroll && meow_scroll_active(g_scroll)) return;
if (g_dragging) return;
uint64_t now = meow_ui_time_ms();
if (now - g_cont_save_ms < MR_CONT_SAVE_MS) return;
g_cont_save_ms = now;
cont_save_now();
}
/* ============================================================ */
/* 页图缓存 */
/* ============================================================ */
static int focus_page(void)
{
if (g_count <= 0) return -1;
if (g_mode == MR_MODE_WEBTOON && g_strip_ok)
return mr_strip_page_at(&g_strip, g_woff + g_vh / 2); /* 视口中心页 */
return g_page;
}
static int in_window(int idx)
{
if (g_count <= 0 || idx < 0 || idx >= g_count) return 0;
for (int i = 0; i < g_win_n; i++)
if (g_win_idx[i] == idx) return 1;
return 0;
}
/* 该页此刻是否真的画在屏幕上(异步落位后只重绘这种情况)。 */
static int page_visible(int idx)
{
if (g_count <= 0 || idx < 0 || idx >= g_count) return 0;
if (g_mode == MR_MODE_WEBTOON && g_strip_ok) {
int f = mr_strip_page_at(&g_strip, g_woff);
int l = mr_strip_page_at(&g_strip, g_woff + g_vh - 1);
return idx >= f && idx <= l;
}
return idx == g_page || (g_slide_delta != 0 && idx == g_page + g_slide_delta);
}
static page_slot *slot_find(int idx)
{
if (idx < 0) return NULL;
for (int i = 0; i < MR_SLOTS; i++)
if (g_slot[i].idx == idx && g_slot[i].img) return &g_slot[i];
return NULL;
}
static void slot_clear(int i)
{
if (i < 0 || i >= MR_SLOTS) return;
if (g_slot[i].img) meow_img_free(g_slot[i].img);
g_slot[i].img = NULL;
g_slot[i].idx = -1;
g_slot[i].src_w = g_slot[i].src_h = 0;
g_slot[i].want_w = g_slot[i].want_h = 0;
g_slot[i].tier = MR_TIER_HIGH;
g_slot[i].hi_tried = 0;
}
static void slots_free_all(void)
{
for (int i = 0; i < MR_SLOTS; i++) slot_clear(i);
}
/* 缓存图是否按「当前显示框」解出来的:比较 slot 记录的 want 几何与当前几何。
* 用几何(而不是 src 像素尺寸)判定,才能同时满足三件事:
* - fit/zoom/模式变化 → 几何变 → 重解一次(放大再缩回、适高→条漫都能回到纯 blit);
* - 解码被单页字节上限钳住(超长条漫/大屏)→ want 不变 → 不再反复重解(无活锁);
* - 源图本身小于显示框(SDK 不做上采样)→ want 不变 → 不会每 tick 重排。 */
static int slot_ready(int idx)
{
page_slot *s = slot_find(idx);
if (!s) return 0;
int w = 0, h = 0;
page_geometry_at(idx, &w, &h);
if (w <= 0 || h <= 0) return 1;
if (s->want_w <= 0 || s->want_h <= 0) return 0;
return abs(w - s->want_w) <= MR_SLOT_TOL && abs(h - s->want_h) <= MR_SLOT_TOL;
}
/* 淘汰离焦点页最远的一张(不淘汰 focus、可见页与 except)。返回被清掉的槽下标,无则 -1。
* M22:可见页不淘汰——条漫视口可能同时露出 2–3 页,按距离淘汰会把屏幕上的页拆掉,
* 结果是「一页闪占位 → 重解码 → 再被拆」的抖动。宁可临时超一点字节,也不让可见页消失。 */
static int slot_evict_far(int except)
{
int focus = focus_page();
int far = -1, far_d = -1;
for (int i = 0; i < MR_SLOTS; i++) {
if (i == except || !g_slot[i].img) continue;
if (g_slot[i].idx == focus) continue;
if (page_visible(g_slot[i].idx)) continue;
int d = abs(g_slot[i].idx - focus);
if (d > far_d) { far_d = d; far = i; }
}
if (far >= 0) slot_clear(far);
return far;
}
/* 开新页前先腾地方(任务 3):按「即将落地的单页字节」预判,先淘汰非可见/非焦点的旧图,
* 让解码缓冲不与陈旧常驻叠加。只在逼近上限时才动手,避免把预取邻居立刻扔掉
*(那样翻页会从「命中」退化成「重新解码」,反而增加峰值次数)。 */
static void slots_reserve(long need_bytes)
{
if (need_bytes <= 0) return;
while (resident_bytes() + need_bytes > MR_RESIDENT_CAP) {
if (slot_evict_far(-1) < 0) return; /* 只剩可见页:放行,下一次 slot_put 再收敛 */
}
}
/* 放进缓存;同页旧图直接替换(fit/zoom/模式变了要换尺寸;低档→高档也走这里)。
* 返回 1=已放入。want_w/want_h = 这次解码按哪个显示框落的,交给 slot_ready 做几何判据。
* tier 记下这一槽的清晰度:低档图先顶上,高档图落地时**原地替换**(同一槽、同一尺寸),
* 页面不闪白、不重置缩放/滚动,常驻也不会一页两张。 */
static int slot_put(int idx, meow_img *img, int want_w, int want_h, int tier)
{
if (!img || idx < 0) return 0;
int i = -1, empty = -1;
for (int k = 0; k < MR_SLOTS; k++) {
if (g_slot[k].idx == idx && g_slot[k].img) { i = k; break; }
if (!g_slot[k].img && empty < 0) empty = k;
}
if (i < 0) {
if (empty >= 0) {
i = empty;
} else { /* 全满:替换离焦点页最远、且不可见的一张 */
int focus = focus_page(), far_d = -1;
for (int k = 0; k < MR_SLOTS; k++) {
if (page_visible(g_slot[k].idx)) continue;
int d = abs(g_slot[k].idx - focus);
if (d > far_d) { far_d = d; i = k; }
}
if (i < 0) i = g_slot[0].img ? 0 : -1; /* 全可见:只能挤掉第一槽(极端条漫页) */
}
}
if (i < 0) { meow_img_free(img); return 0; }
/* 总字节上限:新图放进去前先淘汰最远页,保证常驻图不超过预算。 */
long need = (long)meow_img_w(img) * meow_img_h(img) * 4;
for (;;) {
long held = resident_bytes();
if (g_slot[i].img) held -= (long)g_slot[i].src_w * g_slot[i].src_h * 4;
if (held + need <= MR_RESIDENT_CAP) break;
if (slot_evict_far(i) < 0) break; /* 只剩目标槽:这一页必须放进来 */
}
/* 低档 → 高档替换:低档图在这里释放,不会与高档同时常驻。 */
slot_clear(i);
g_slot[i].idx = idx;
g_slot[i].img = img;
g_slot[i].src_w = meow_img_w(img);
g_slot[i].src_h = meow_img_h(img);
g_slot[i].want_w = want_w;
g_slot[i].want_h = want_h;
g_slot[i].tier = (unsigned char)(tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH);
return 1;
}
/* ============================================================ */
/* 解码窗计算(≤MR_SLOTS 页,按优先级:跳页目标 > 焦点 > 视口 ±1) */
/* ============================================================ */
static void win_reset(void)
{
g_win_n = 0;
}
static void win_add(int idx)
{
if (idx < 0 || idx >= g_count || g_win_n >= MR_SLOTS) return;
for (int i = 0; i < g_win_n; i++)
if (g_win_idx[i] == idx) return;
g_win_idx[g_win_n++] = idx;
}
static void window_compute(void)
{
win_reset();
if (g_count <= 0) return;
int jt = mr_jump_target(&g_jump);
if (jt >= 0) win_add(jt); /* 跳页目标优先,否则会被窗口淘汰 */
int focus = focus_page();
win_add(focus);
if (g_mode == MR_MODE_WEBTOON && g_strip_ok) {
int f = 0, l = 0;
int budget = MR_SLOTS - g_win_n; /* 可见±1;剩余槽位给视口 */
if (budget < 1) budget = 1;
mr_strip_window(&g_strip, g_woff, budget, &f, &l);
for (int i = f; i <= l; i++) win_add(i);
/* 视口窗口可能因预算被截:再补焦点两侧各一页,保证滚动方向有前瞻 */
for (int d = 1; d <= MR_SLOTS; d++) {
win_add(focus - d);
win_add(focus + d);
}
} else if (g_zoom_pct > 100) {
win_add(g_page); /* 放大:只驻留当前页,给缩放临时缓冲让内存 */
} else {
win_add(g_page - 1);
win_add(g_page + 1);
}
}
/* ============================================================ */
/* 后台解码 */
/* ============================================================ */
static int req_queued(int idx)
{
for (int i = 0; i < g_req_n; i++) {
int p = (g_req_head + i) % MR_REQ_MAX;
if (g_req[p].idx == idx) return 1;
}
return 0;
}
/* 预取闸门(任务 4):拖动/惯性期间只解落点页,不做邻居预取;且只在内存确实富裕时预取。
* MemAvailable 读不到按「不预取」处理(保守)。
* M31 r2(评审 P1-2):分页模式下焦点页还是低档 → 停稳 150ms 后就要换清晰,而换清晰为了
* 压低两档共存峰值会腾掉非可见页;此刻预取的邻居正好会被挤掉(白解一次,且换完不补就
* 每次翻页 miss)。所以「焦点页等着换清晰」期间不预取邻居,等高档落位后由 sharpen_refill()
* 按翻页方向补回来。低档已放弃升级(hi_tried)或焦点已清晰时恢复正常预取。 */
static int prefetch_allowed(void)
{
if (g_dragging) return 0;
if (g_scroll && meow_scroll_active(g_scroll)) return 0;
if (g_mode == MR_MODE_PAGED) {
int f = focus_page();
page_slot *s = f >= 0 ? slot_find(f) : NULL;
if (s && s->tier == MR_TIER_LOW && !s->hi_tried && page_needs_tier(f)) return 0;
}
long a = mem_avail_kb();
if (a < 0) return 0;
return a >= MR_PREFETCH_MIN_KB;
}
/* 大页才分档:真机 1280×2275 / 1280×1032 这类页单次解码 168–400ms;小图本来就快,
* 分两档只会多一次解码 + 落框重采样。 */
static int page_needs_tier(int idx)
{
int sw = 0, sh = 0;
if (!page_meta_dims(idx, &sw, &sh)) return 0;
return (long long)sw * sh >= MR_TIER_MIN_PIXELS;
}
/* 排一个解码请求。tier 决定解低档还是目标档位;sharpen=1 表示「停稳后换清晰」的追加
* 请求——动起来(拖动/翻页/滚动)就由 gen/窗口/sharpen 三条判据一起作废。 */
static int queue_page_tier(int idx, int tier, int sharpen)
{
/* 几何在这里(主线程)算好塞进请求:worker 不再读 g_fit/g_zoom/g_vw/g_vh。 */
int want_w = 0, want_h = 0;
page_geometry_at(idx, &want_w, &want_h);
if (want_w <= 0 || want_h <= 0) { want_w = g_vw; want_h = g_vh; }
int box_w = 0, box_h = 0;
plan_box_size(want_w, want_h, &box_w, &box_h);
/* 先释放再解码:按这一页的落地字节先淘汰不可见旧图,别让常驻与解码缓冲叠加。 */
slots_reserve((long)box_w * box_h * 4);
int queued = 0;
pthread_mutex_lock(&g_lock);
/* 去重:同一页只要已排队或已交给 worker(g_req_inflight)就不再发。后者必须单列:
* 请求被 worker 取走后就不在队列里了,只看 req_queued 会在每次窗口刷新时重排一页,
* 重复解码还会把刚预取的邻居挤掉(评审 P1-2 的 4 次/页、0 命中)。 */
int busy = idx >= 0 && idx < MR_BOOK_MAX_PAGES && g_req_inflight[idx];
if (!g_stop && !busy && !req_queued(idx) && g_req_n < MR_REQ_MAX) {
page_req *r = &g_req[g_req_tail];
r->idx = idx;
r->gen = g_gen;
r->want_w = want_w;
r->want_h = want_h;
r->box_w = box_w;
r->box_h = box_h;
r->tier = (unsigned char)(tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH);
r->sharpen = (unsigned char)(sharpen ? 1 : 0);
r->resident = (long long)resident_bytes(); /* 腾完地方之后的常驻快照(两档共存峰值) */
g_req_tail = (g_req_tail + 1) % MR_REQ_MAX;
g_req_n++;
if (idx >= 0 && idx < MR_BOOK_MAX_PAGES) g_req_inflight[idx] = 1;
queued = 1;
pthread_cond_signal(&g_cv);
}
pthread_mutex_unlock(&g_lock);
return queued;
}
static void queue_page(int idx)
{
if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return;
/* 永久失败与「仍在跳过期」的页都不排;后者由 maybe_retry_skipped_focus 解封。 */
if (page_blocked(idx)) return;
page_slot *s = slot_find(idx);
if (g_req_inflight[idx]) return; /* 已排队/在解:别重排(重复解码会挤掉邻居) */
if (s && slot_ready(idx)) {
/* 已经能显示:只有「低档 → 目标档位」这一种情况还要再解,而且必须是当前焦点页、
* 已停稳(g_sharpen_idx 由 tier_pump 在去抖窗口后置位)、并且最近一次升级没失败过。
* hi_tried 只在「解坏了 / 预算跳过」时置位(避免每 tick 重试);拖动取消会复位。 */
if (s->tier != MR_TIER_LOW || s->hi_tried || g_sharpen_idx != idx ||
!page_needs_tier(idx))
return;
queue_page_tier(idx, MR_TIER_HIGH, 1);
return;
}
queue_page_tier(idx, page_needs_tier(idx) ? MR_TIER_LOW : MR_TIER_HIGH, 0);
}
static void *reader_worker(void *arg)
{
(void)arg;
for (;;) {
page_req req;
pthread_mutex_lock(&g_lock);
while (!g_stop && g_req_n == 0) pthread_cond_wait(&g_cv, &g_lock);
if (g_stop) { pthread_mutex_unlock(&g_lock); break; }
req = g_req[g_req_head];
g_req_head = (g_req_head + 1) % MR_REQ_MAX;
g_req_n--;
/* 取消(任务 4 / M20 P0-3;M31 扩展):出队后、mr_book_open 前二次判过期——旧册
* (gen 变)或已经不在解码窗(拖动/快翻的中间页)直接丢弃。M20 实测拖动翻页原本
* 1.75× 解码,全是这种在飞不可取消的请求。锁内判,窗口/sharpen 目标也在锁内写,
* 避免共享状态竞争。
* M31:拖动/连翻期间只许解低档——「停稳换清晰」的请求一旦动起来就作废(复用同一条
* stale 判据,不新造取消通道)。 */
int stale = (req.gen != g_gen) || !in_window(req.idx);
int stale_motion = 0;
if (!stale && req.sharpen && req.idx != g_sharpen_idx) {
stale = 1;
stale_motion = 1;
}
if (stale && req.idx >= 0 && req.idx < MR_BOOK_MAX_PAGES) g_req_inflight[req.idx] = 0;
pthread_mutex_unlock(&g_lock);
if (stale) {
if (stale_motion) g_high_canceled++;
meow_log(MEOW_LOG_INFO,
"reader decode page=%d src=0x0 peak=0kB memfree=%ldkB memavail=%ldkB skip=cancel step=0 tier=%s",
req.idx, mem_free_kb(), mem_avail_kb(),
req.tier == MR_TIER_LOW ? "low" : "high");
continue;
}
/* 解码计划:本线程私有(单 worker),解码回调只读这几个量。 */
g_plan.want_w = req.want_w;
g_plan.want_h = req.want_h;
g_plan.box_w = req.box_w;
g_plan.box_h = req.box_h;
g_plan.tier = req.tier;
g_plan.sharpen = req.sharpen;
g_plan.resident = req.resident;
g_plan_page = req.idx;
g_decode_skip = MR_SKIP_NONE;
g_decode_tier = MR_TIER_HIGH;
meow_img *img = mr_book_open(&g_book, req.idx);
g_decode_count++;
/* 档位以解码回调**实际生效**的为准:低档请求遇到非 JPEG/不值得分档的页会降为
* 单档(reader_load_page 把 g_decode_tier 置回 HIGH),落槽也就不会误标成低档。 */
int eff_tier = g_decode_tier;
if (eff_tier == MR_TIER_LOW) g_low_decodes++;
else g_high_decodes++;
int skip = img ? MR_SKIP_NONE : g_decode_skip;
pthread_mutex_lock(&g_lock);
if (!g_stop && g_res_n < MR_RES_MAX) {
g_res[g_res_n].idx = req.idx;
g_res[g_res_n].gen = req.gen;
g_res[g_res_n].img = img;
g_res[g_res_n].fail = (img || skip) ? 0 : 1;
g_res[g_res_n].skip = skip;
g_res[g_res_n].want_w = req.want_w;
g_res[g_res_n].want_h = req.want_h;
g_res[g_res_n].tier = (unsigned char)eff_tier;
g_res[g_res_n].sharpen = req.sharpen;
g_res_n++;
img = NULL; /* 所有权已移交(失败/跳过时本就是 NULL) */
}
/* 本页请求已经走完(无论落不落槽):解除去重标记,允许下一轮重新判定。 */
if (req.idx >= 0 && req.idx < MR_BOOK_MAX_PAGES) g_req_inflight[req.idx] = 0;
pthread_mutex_unlock(&g_lock);
if (img) meow_img_free(img);
}
return NULL;
}
static void worker_start(void)
{
if (g_worker_on) return;
g_stop = 0;
if (pthread_create(&g_worker, NULL, reader_worker, NULL) == 0) g_worker_on = 1;
}
static void worker_stop(void)
{
if (!g_worker_on) return;
pthread_mutex_lock(&g_lock);
g_stop = 1;
pthread_cond_broadcast(&g_cv);
pthread_mutex_unlock(&g_lock);
pthread_join(g_worker, NULL);
g_worker_on = 0;
pthread_mutex_lock(&g_lock);
for (int i = 0; i < g_res_n; i++) meow_img_free(g_res[i].img);
g_res_n = 0;
g_req_n = 0;
g_req_head = g_req_tail = 0;
memset(g_req_inflight, 0, sizeof g_req_inflight);
pthread_mutex_unlock(&g_lock);
}
/* 丢弃窗口外的排队请求(主线程调用)。M31:还没出队的「停稳换清晰」请求,一旦动起来
* (g_sharpen_idx 被清)也在这里作废,不必等 worker 出队才发现。 */
static void queue_prune(void)
{
pthread_mutex_lock(&g_lock);
page_req keep[MR_REQ_MAX];
int n = 0;
for (int i = 0; i < g_req_n; i++) {
page_req r = g_req[(g_req_head + i) % MR_REQ_MAX];
int drop = 0;
if (r.gen != g_gen || !in_window(r.idx)) drop = 1;
if (!drop && r.sharpen && r.idx != g_sharpen_idx) {
g_high_canceled++;
drop = 1;
}
/* 几何已变的请求作废(否则会按旧显示框解出来又被判过期)。 */
if (!drop) {
int w = 0, h = 0;
page_geometry_at(r.idx, &w, &h);
if (abs(w - r.want_w) > MR_SLOT_TOL || abs(h - r.want_h) > MR_SLOT_TOL) drop = 1;
}
if (drop) {
if (r.idx >= 0 && r.idx < MR_BOOK_MAX_PAGES) g_req_inflight[r.idx] = 0;
continue;
}
keep[n++] = r;
}
for (int i = 0; i < n; i++) g_req[i] = keep[i];
g_req_head = 0;
g_req_tail = n;
g_req_n = n;
pthread_mutex_unlock(&g_lock);
}
/* 收取后台结果:只留窗口内新页。返回 1=有图落位。
* 结果分三种:ok(落槽)/ fail(坏图,永久占位)/ skip(预算跳过,可重试占位)。
* M31:高档(停稳换清晰)失败/跳过时,低档已经在屏幕上——保留低档继续看,不把整页标成
* 「内存不足」或坏图;低档自己的失败/跳过才走旧语义(那才是真的没图)。 */
static int results_drain(void)
{
page_res got[MR_RES_MAX];
int n;
pthread_mutex_lock(&g_lock);
n = g_res_n;
for (int i = 0; i < n; i++) got[i] = g_res[i];
g_res_n = 0;
pthread_mutex_unlock(&g_lock);
int changed = 0;
for (int i = 0; i < n; i++) {
if (got[i].gen != g_gen) { meow_img_free(got[i].img); continue; }
page_slot *low = got[i].tier == MR_TIER_HIGH ? slot_find(got[i].idx) : NULL;
if (low && low->tier != MR_TIER_LOW) low = NULL; /* 已有目标档:不是「低档兜底」 */
if (got[i].skip) {
/* 预算跳过(内存不足/峰值超限/两档共存超上限):可重试,不写永久失败位。 */
if (low) { low->hi_tried = 1; continue; } /* 低档在屏:降级显示,不占位 */
page_mark_skipped(got[i].idx);
if (page_visible(got[i].idx)) g_fail_paint = 1;
continue;
}
if (got[i].fail) {
if (low) { low->hi_tried = 1; continue; }
page_mark_failed(got[i].idx);
if (page_visible(got[i].idx)) g_fail_paint = 1;
continue;
}
if (in_window(got[i].idx)) {
if (slot_put(got[i].idx, got[i].img, got[i].want_w, got[i].want_h,
got[i].tier) && page_visible(got[i].idx))
changed = 1;
page_skip_clear(got[i].idx); /* 解出来了:撤销跳过标记 */
/* 换清晰落位:换清晰前为压峰值腾掉了邻居,这里补回(P1-2)。
* 普通预取/首次解码不腾邻居,不需要补。 */
if (got[i].sharpen) g_refill = 1;
} else {
meow_img_free(got[i].img);
}
}
return changed;
}
/* 预算跳过的页如何重试(任务 2 的可重试语义):只对**焦点页**重试,且冷却 MR_SKIP_RETRY_MS,
* 这样「内存暂时不够」不会变成永久占位,也不会变成每 tick 一次的重试风暴。
* force=1 是用户显式重试(点按占位页)。 */
static void maybe_retry_skipped_focus(int force)
{
int f = focus_page();
if (f < 0 || !page_skipped(f) || page_failed(f)) return;
if (!force) {
uint64_t now = meow_ui_time_ms();
if (now - g_skip_ms < MR_SKIP_RETRY_MS) return;
}
page_skip_clear(f);
g_skip_ms = meow_ui_time_ms();
meow_log(MEOW_LOG_INFO, "reader decode retry page=%d%s", f + 1, force ? " (tap)" : "");
}
/* 窗口随视口/当前页移动:淘汰窗口外图 → 清掉窗口外请求 → 按优先级补页。
* 窗口本身在锁内写(worker 的取消判据要读它)。 */
static void window_update(void)
{
pthread_mutex_lock(&g_lock);
window_compute();
pthread_mutex_unlock(&g_lock);
/* 不主动清窗口外的图:多留一页「回看缓存」,避免 0→1→0 来回翻时反复重解同一页。
* 容量仍由 MR_SLOTS 与 slot_put 的字节淘汰兜底(最多 MR_SLOTS 张)。 */
queue_prune();
/* 回到某页 → 允许重试这一页(「离开再回来」的可重试语义)。 */
maybe_retry_skipped_focus(0);
/* 队列第一位 = 跳页目标或焦点页(必解);其余是预取,按内存闸门决定做不做。 */
int prefetch = prefetch_allowed();
for (int i = 0; i < g_win_n; i++) {
if (i > 0 && !prefetch) continue;
queue_page(g_win_idx[i]);
}
}
/* ============================================================ */
/* 两级清晰度的停稳去抖(M31) */
/* ------------------------------------------------------------
* 规则只有三条:
* 1) 拖动 / 吸附动画 / 条漫滚动(含 fling)期间一律不动高档:g_sharpen_idx=-1,
* 在飞/排队的「停稳换清晰」请求按 M22 的取消通道作废;跳页在途时同样不解高档
* (单 worker 串行,导航优先);
* 2) 停稳 MR_SHARPEN_MS 后,焦点页若是低档,才允许它解目标档位(g_sharpen_idx=focus);
* 3) 每次「动」都重置停稳计时;换页/换几何也会清掉资格(重新计时)。
* 高档只发给焦点页:单 worker 串行,先保证用户正看的那页清晰,邻居留低档待翻到再说。 */
static int motion_active(void)
{
if (g_dragging) return 1;
if (g_snap || g_bounce) return 1;
if (g_scroll && meow_scroll_active(g_scroll)) return 1;
return 0;
}
/* 「动」了:重置停稳计时,并取消当前的高档资格(排队/在飞的请求随之作废)。
* 被取消的这一页如果还是低档,把 hi_tried 复位——停稳后还能再试一次。 */
static void settle_reset(void)
{
g_move_ms = meow_ui_time_ms();
if (g_sharpen_idx < 0) return;
page_slot *s = slot_find(g_sharpen_idx);
if (s && s->tier == MR_TIER_LOW) s->hi_tried = 0;
pthread_mutex_lock(&g_lock);
g_sharpen_idx = -1;
pthread_mutex_unlock(&g_lock);
queue_prune();
}
static void tier_pump(uint64_t now)
{
if (g_count <= 0 || g_quitting) return;
if (motion_active()) {
g_move_ms = now;
if (g_sharpen_idx >= 0) settle_reset();
return;
}
/* 跳页在途:单 worker 串行,「换清晰」是锦上添花,不能插在跳页目标的低档前面——
* 否则滑块一拖就是几秒(早已过停稳窗口),老页的高档会把跳页落位往后挤几百 ms。 */
if (mr_jump_active(&g_jump)) {
if (g_sharpen_idx >= 0) settle_reset();
return;
}
int f = focus_page();
if (f < 0) return;
page_slot *s = slot_find(f);
if (!s || s->tier != MR_TIER_LOW || s->hi_tried || !page_needs_tier(f)) {
if (g_sharpen_idx == f) {
pthread_mutex_lock(&g_lock);
g_sharpen_idx = -1;
pthread_mutex_unlock(&g_lock);
}
return;
}
if (now - g_move_ms < (uint64_t)g_sharpen_ms) return; /* 还没停稳 */
if (g_sharpen_idx != f) {
pthread_mutex_lock(&g_lock);
g_sharpen_idx = f;
pthread_mutex_unlock(&g_lock);
}
queue_page(f); /* 幂等:已排队/在飞/已清晰/已试过都直接返回 */
}
/* 高档落位后补邻居(M31 r2 / 评审 P1-2)。
* 换清晰前 slots_reserve 会腾掉非可见页来压两档共存峰值,顺序翻页时被腾掉的正是
* 「下一页」的预取;不补回来,每次翻页都会 miss(基线 8/8 → 分档后 0/8)。
* 只补最近一次翻页方向上的邻居:反方向那张在 3MB 常驻上限下放不下(必然被下一次预取
* 挤掉),白解一次还抬高解码次数;回头看时按需解一版低档——低档本来就是为这个设计的。 */
static void sharpen_refill(void)
{
if (g_mode != MR_MODE_PAGED || g_count <= 0 || g_quitting) return;
int f = focus_page();
if (f < 0) return;
int fwd = f + (g_step_dir >= 0 ? 1 : -1);
if (fwd >= 0 && fwd < g_count && !slot_find(fwd)) queue_page(fwd);
}
/* ============================================================ */
/* 条漫排版 + 滚动会话 */
/* ============================================================ */
static int strip_meta_cb(void *ctx, int index, int *w, int *h)
{
(void)ctx;
return page_meta_dims(index, w, h); /* 走缓存:重排版不再逐页 fopen probe */
}
static int strip_ensure(void)
{
if (g_strip_ok && g_strip.count == g_count &&
g_strip.vw == g_vw && g_strip.vh == g_vh)
return 1;
mr_strip_free(&g_strip);
g_strip_ok = 0;
if (g_count <= 0) return 0;
uint64_t t0 = meow_ui_time_ms();
if (mr_strip_build(&g_strip, g_count, g_vw, g_vh, strip_meta_cb, &g_book) != 0) {
meow_log(MEOW_LOG_WARN, "reader strip layout failed pages=%d", g_count);
return 0;
}
g_strip_ok = 1;
meow_log(MEOW_LOG_INFO, "reader strip layout pages=%d total=%d ms=%llu",
g_count, mr_strip_total(&g_strip),
(unsigned long long)(meow_ui_time_ms() - t0));
return 1;
}
static int wo_max(void *ctx)
{
(void)ctx;
return mr_strip_max_offset(&g_strip);
}
static int wo_get(void *ctx)
{
(void)ctx;
return g_woff;
}
static void wo_set(void *ctx, int off)
{
(void)ctx;
int m = mr_strip_max_offset(&g_strip);
if (off < 0) off = 0;
if (off > m) off = m;
if (off == g_woff) return;
g_woff = off;
window_update();
cont_touch();
/* 重绘由调用方节流:拖动走 drag_paint(),fling 走 wo_redraw()。 */
}
static void wo_redraw(void *ctx)
{
(void)ctx;
invalidate_content(); /* 滚动只动内容区,顶栏静态不重绘 */
}
static void scroll_ensure(void)
{
if (g_scroll) return;
g_scroll = meow_scroll_create(wo_set, wo_max, wo_get, wo_redraw, NULL);
if (!g_scroll) meow_log(MEOW_LOG_WARN, "reader scroll session unavailable (pool full)");
}
static void scroll_free(void)
{
if (!g_scroll) return;
meow_scroll_free(g_scroll);
g_scroll = NULL;
}
/* ============================================================ */
/* 布局/绘制 */
/* ============================================================ */
/* 内容视口失效区:chrome 可见时顶栏是静态的,拖动/吸附帧只刷其下内容,
* 避免每帧重绘顶栏文字(软渲下文字是最贵的绘制物)。 */
static void invalidate_content(void)
{
if (g_chrome)
meow_ui_invalidate(0, MR_TOP_BAR_H, g_vw, g_vh - MR_TOP_BAR_H);
else
meow_ui_invalidate(0, 0, g_vw, g_vh);
}
static void current_geom(int *w, int *h)
{
page_target_size_at(g_page, w, h);
if (!w || !h || *w <= 0 || *h <= 0) {
if (w) *w = g_vw;
if (h) *h = g_vh;
}
}
static void clamp_pan(void)
{
int w, h;
current_geom(&w, &h);
mr_nav_pan_clamp(w, h, g_vw, g_vh, &g_pan_x, &g_pan_y);
}
/* 内容是否超出视口(原始档的方图/宽图即使不放大也需要平移)。 */
/* 横向是否真的溢出(横向平移才有意义)。适宽长图只会在**竖向**溢出:那属于「页面比
* 屏幕略高」,不能因此把左右 tap / 横拖从翻页改成平移——否则 1.5 以上长宽比的页
* 在适宽档完全翻不动(水平平移量本就是 0,点击变空操作)。 */
static int content_h_overflow(void)
{
int w = 0, h = 0;
current_geom(&w, &h);
return w > g_vw;
}
/* 平移模式:放大、横向溢出(原始档宽图 / 适高宽图)、或原始档任意溢出。
* 此时单指拖动=平移,左右 tap=平移步进。
* 适宽/适高的**竖向单独溢出**不算(适宽长图常见:1.6 比例页在 480x720 上高 768);
* 若把它算成平移,左右 tap / 横拖会变成「水平平移 0px」的空操作 —— 页面直接翻不动。 */
static int pan_mode(void)
{
if (g_zoom_pct > 100) return 1;
if (content_h_overflow()) return 1;
if (g_fit == MR_FIT_ORIGINAL) {
int w = 0, h = 0;
current_geom(&w, &h);
return h > g_vh;
}
return 0;
}
/* 画一页(带水平位移);解码尺寸==显示尺寸时走原生 blit(翻页热路径零重采样),
* 只在 fit/zoom 变化或极端长页(超单页缓存上限)时才退回最近邻缩放。 */
static void draw_page_at(int idx, int off_x, int off_y)
{
page_slot *s = slot_find(idx);
if (!s) return;
int w, h;
page_target_size_at(idx, &w, &h);
if (w <= 0 || h <= 0) return;
int x = (g_vw - w) / 2 + g_pan_x + off_x;
int y = (g_vh - h) / 2 + g_pan_y + off_y;
if (abs(w - s->src_w) <= MR_SLOT_TOL && abs(h - s->src_h) <= MR_SLOT_TOL) {
meow_ui_img(x + (w - s->src_w) / 2, y + (h - s->src_h) / 2, s->img);
NOTE_BLIT();
} else {
meow_ui_img_scaled(x, y, w, h, s->img);
NOTE_SCALED();
}
}
/* 条漫:按滚动偏移画可见页。典型 2:3 页的解码尺寸与显示尺寸相等 → 原生 blit;
* 其余按最近邻缩放(缩放在 SDK 内部申请目标尺寸临时缓冲,单页峰值受控)。 */
static void draw_strip(const meow_theme *th)
{
if (!g_strip_ok) {
meow_ui_text_center(g_vw / 2, g_vh / 2, "正在排版…", th->font_body, th->onSurfaceVariant);
return;
}
int first = mr_strip_page_at(&g_strip, g_woff);
int last = mr_strip_page_at(&g_strip, g_woff + g_vh - 1);
for (int i = first; i <= last; i++) {
int y = mr_strip_top(&g_strip, i) - g_woff;
int h = mr_strip_page_h(&g_strip, i);
if (h <= 0) continue;
page_slot *s = slot_find(i);
if (s) {
/* 解码=显示尺寸时原生 blit:gfx 只处理屏幕上可见的行,
* 长条页只露一截时不再整页重采样(M7 finding 的子矩形 blit 诉求)。 */
if (abs(s->src_w - g_vw) <= MR_SLOT_TOL && abs(s->src_h - h) <= MR_SLOT_TOL) {
meow_ui_img((g_vw - s->src_w) / 2, y, s->img);
NOTE_BLIT();
} else {
meow_ui_img_scaled(0, y, g_vw, h, s->img);
NOTE_SCALED();
}
continue;
}
/* 未就绪:只填可见部分(长页不整块清屏),给一帧占位而不是白屏。 */
int y0 = y < 0 ? 0 : y;
int y1 = y + h;
if (y1 > g_vh) y1 = g_vh;
if (y1 <= y0) continue;
meow_ui_fill_rect(0, y0, g_vw, y1 - y0, th->surfaceContainer);
if (y1 - y0 >= 48)
meow_ui_text_center(g_vw / 2, y0 + (y1 - y0) / 2 - 10,
page_placeholder(i),
th->font_body, th->onSurfaceVariant);
}
}
static void draw_toast(const meow_theme *th)
{
if (!g_toast[0] || meow_ui_time_ms() >= g_toast_until) return;
int tw = meow_ui_text_width(g_toast, th->font_body) + 40;
if (tw > g_vw - 16) tw = g_vw - 16;
int x = (g_vw - tw) / 2;
int y = g_vh - 120;
meow_ui_fill_rect_alpha(x, y, tw, 44, th->surfaceContainerHighest, 235);
meow_ui_text_center(g_vw / 2, y + 12, g_toast, th->font_body, th->onSurface);
}
static void reader_draw(meow_view *v, const meow_theme *th, void *ctx)
{
(void)v; (void)ctx;
int w = g_vw, h = g_vh;
if (g_open_err) {
meow_ui_text_center(w / 2, h / 2 - 20, "无法打开此册", th->font_title, th->onSurface);
meow_ui_text_center(w / 2, h / 2 + 24, g_dir, th->font_body, th->onSurfaceVariant);
return;
}
if (g_count <= 0) {
meow_ui_text_center(w / 2, h / 2 - 20, "空册", th->font_title, th->onSurface);
meow_ui_text_center(w / 2, h / 2 + 24, "这个文件夹里没有图片", th->font_body,
th->onSurfaceVariant);
return;
}
if (g_mode == MR_MODE_WEBTOON) {
draw_strip(th);
draw_toast(th);
return;
}
if (g_slide_delta != 0) {
/* 动画帧:进场页从 from 滑到 0,当前页同步让位(只 blit 两张已缓存页图)。 */
draw_page_at(g_page, g_anim_v - g_slide_from, 0);
draw_page_at(g_page + g_slide_delta, g_anim_v, 0);
draw_toast(th);
return;
}
if (g_drag_mode == 1 && g_drag_off != 0) {
/* 跟手:当前页按位移走,相邻页在另一侧同时露出(未就绪就只画当前页)。 */
int rtl = mr_reader_get_direction() == MR_DIR_RTL;
int delta = mr_nav_swipe_delta(rtl, g_drag_off);
int target = mr_nav_clamp_page(g_page + delta, g_count);
if (target != g_page && slot_find(target)) {
int from = mr_nav_slide_from(rtl, delta, g_vw);
draw_page_at(target, g_drag_off + from, 0);
}
draw_page_at(g_page, g_drag_off, 0);
draw_toast(th);
return;
}
draw_page_at(g_page, 0, 0);
if (!slot_find(g_page)) {
meow_ui_text_center(w / 2, h / 2,
page_placeholder(g_page),
th->font_body, th->onSurfaceVariant);
}
draw_toast(th);
}
/* ============================================================ */
/* 状态动作 */
/* ============================================================ */
static void update_title(void)
{
if (!g_tb) return;
static const char *const fit_name[MR_FIT_COUNT] = { "适宽", "适高", "原始" };
const char *fit = (g_fit >= 0 && g_fit < MR_FIT_COUNT) ? fit_name[g_fit] : "适宽";
char buf[64];
if (g_count > 0) {
if (g_mode == MR_MODE_WEBTOON)
snprintf(buf, sizeof buf, "%d / %d 条漫", current_page() + 1, g_count);
else
snprintf(buf, sizeof buf, "%d / %d 翻页 %s", g_page + 1, g_count, fit);
} else {
snprintf(buf, sizeof buf, "0 / 0");
}
meow_topappbar_set_title(g_tb, buf);
g_title_page = (g_count > 0) ? current_page() : -1;
}
static void toast(const char *msg)
{
snprintf(g_toast, sizeof g_toast, "%s", msg ? msg : "");
g_toast_until = meow_ui_time_ms() + MR_TOAST_MS;
meow_ui_invalidate(0, g_vh - 140, g_vw, 140);
}
static void slider_place(void)
{
if (!g_slider) return;
int visible = g_chrome && g_jump_open && g_count > 1;
if (visible)
meow_widget_set_rect(g_slider, 24, MR_TOP_BAR_H + 4, g_vw - 48, MR_SLIDER_H);
else
meow_widget_set_rect(g_slider, 0, -(MR_TOP_BAR_H + MR_SLIDER_H + 8),
g_vw - 48, MR_SLIDER_H);
}
static void slider_build(void)
{
if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; }
if (g_count <= 1) return;
int val = current_page() + 1;
if (val < 1) val = 1;
if (val > g_count) val = g_count;
g_slider = meow_slider_create(0, 0, g_vw - 48, 1, g_count, val);
slider_place();
}
static void slider_sync(void)
{
if (!g_slider || g_count <= 0) return;
int v = current_page() + 1;
if (v < 1) v = 1;
if (v > g_count) v = g_count;
meow_slider_set(g_slider, v);
}
static void set_fit(int fit)
{
if (g_mode == MR_MODE_WEBTOON) {
toast("条漫固定适宽");
return;
}
g_fit = (fit < 0 || fit >= MR_FIT_COUNT) ? MR_FIT_WIDTH : fit;
g_zoom_pct = 100;
g_pan_x = g_pan_y = 0;
settle_reset(); /* 几何变了:停稳计时重来,低档先上屏再换清晰 */
window_update();
update_title();
meow_ui_invalidate(0, 0, g_vw, g_vh);
}
static void set_chrome(int on)
{
on = on ? 1 : 0;
if (g_chrome == on) return;
g_chrome = on;
if (g_tb) meow_widget_set_rect(g_tb, 0, on ? 0 : -MR_TOP_BAR_H, g_vw, MR_TOP_BAR_H);
slider_place();
meow_ui_invalidate(0, 0, g_vw, MR_TOP_BAR_H + MR_SLIDER_H + 8);
}
/* 平移(dx>0 = 内容右移);放大或原始档溢出时可用。 */
static void pan_by(int dx, int dy)
{
if (!pan_mode()) return;
g_pan_x += dx;
g_pan_y += dy;
clamp_pan();
invalidate_content();
}
static void commit_page(int target)
{
int prev = g_page;
target = mr_nav_clamp_page(target, g_count);
if (target < 0) return;
g_page = target;
if (target != prev) g_step_dir = target > prev ? 1 : -1; /* 高档落位后按这个方向补邻居 */
g_zoom_pct = 100;
g_pan_x = g_pan_y = 0;
settle_reset(); /* 换页:停稳计时重来(拖动/连翻期间不解高档) */
window_update();
update_title();
slider_sync();
cont_touch();
meow_ui_invalidate(0, 0, g_vw, g_vh);
}
/* ---- 翻页滑动吸附 ---- */
static void slide_update(meow_anim *a, float value, void *ctx)
{
(void)a; (void)ctx;
g_anim_v = (int)value;
invalidate_content(); /* 吸附帧只刷内容区(整屏 minus 静态顶栏) */
}
static void slide_done(meow_anim *a, int canceled, void *ctx)
{
(void)a; (void)ctx;
g_snap = NULL;
if (g_quitting) return;
int delta = g_slide_delta;
g_slide_delta = 0;
g_slide_from = 0;
g_anim_v = 0;
if (!canceled && delta)
commit_page(g_page + delta);
else
invalidate_content();
meow_log(MEOW_LOG_INFO, "reader flip done page=%d/%d total_ms=%llu",
g_page + 1, g_count,
(unsigned long long)(meow_ui_time_ms() - g_flip_t0));
}
/* 从 start_off(松手时的位移,无跟手时为 0)滑到目标页。1=动画已启动。 */
static int start_slide(int delta, int start_off)
{
if (g_snap || g_bounce) return 0;
int rtl = mr_reader_get_direction() == MR_DIR_RTL;
g_slide_delta = delta;
g_slide_from = mr_nav_slide_from(rtl, delta, g_vw);
g_anim_v = start_off + g_slide_from;
meow_anim_desc d = {
(float)g_anim_v, 0.0f, MR_SLIDE_MS, MEOW_EASE_OUT,
slide_update, slide_done, NULL,
};
uint64_t t0 = meow_ui_time_ms();
g_snap = meow_anim_start(&d); /* start 即回调 update:首帧在这里绘制 */
uint64_t dt = meow_ui_time_ms() - t0;
if (!g_snap) {
g_slide_delta = 0;
g_slide_from = 0;
g_anim_v = 0;
commit_page(g_page + delta);
return 0;
}
meow_log(MEOW_LOG_INFO, "reader slide first_frame_ms=%llu page=%d->%d",
(unsigned long long)dt, g_page + 1, g_page + delta + 1);
return 1;
}
/* ---- 未过阈值回弹 ---- */
static void bounce_update(meow_anim *a, float value, void *ctx)
{
(void)a; (void)ctx;
g_drag_off = (int)value;
invalidate_content();
}
static void bounce_done(meow_anim *a, int canceled, void *ctx)
{
(void)a; (void)canceled; (void)ctx;
g_bounce = NULL;
g_drag_off = 0;
g_drag_mode = 0;
if (!g_quitting) invalidate_content();
}
static void start_bounce(int from_off)
{
if (g_bounce || g_snap) { g_drag_off = 0; g_drag_mode = 0; return; }
meow_anim_desc d = {
(float)from_off, 0.0f, MR_BOUNCE_MS, MEOW_EASE_OUT,
bounce_update, bounce_done, NULL,
};
g_bounce = meow_anim_start(&d);
if (!g_bounce) { g_drag_off = 0; g_drag_mode = 0; invalidate_content(); }
}
/* 点击触发的翻页(无跟手位移)。首/末页再前进 → 相邻册(无相邻册则轻提示)。 */
static void page_flip(int delta)
{
if (delta == 0 || g_count <= 0) return;
int target = mr_nav_clamp_page(g_page + delta, g_count);
if (target == g_page) { /* 首/末页:册间衔接 */
book_switch(delta > 0 ? 1 : -1);
return;
}
if (g_snap || g_bounce) return; /* 短动画中忽略连点 */
if (g_zoom_pct != 100) { /* 翻页回到基准缩放 */
g_zoom_pct = 100;
g_pan_x = g_pan_y = 0;
}
results_drain();
g_flip_t0 = meow_ui_time_ms();
if (slot_find(target)) {
g_flip_hits++;
if (start_slide(delta, 0))
meow_log(MEOW_LOG_INFO, "reader flip %d->%d hit", g_page + 1, target + 1);
else
meow_log(MEOW_LOG_INFO, "reader flip %d->%d hit (no anim slot)", g_page + 1, target + 1);
} else {
g_flip_misses++;
meow_log(MEOW_LOG_INFO, "reader flip %d->%d miss (decode pending)", g_page + 1, target + 1);
commit_page(target);
}
}
/* ============================================================ */
/* 跳页(chrome 滑块):目标页预解码完成后落位 */
/* ============================================================ */
static void jump_land(int page, int timed_out)
{
if (g_count <= 0) return;
int prev = current_page();
page = mr_nav_clamp_page(page, g_count);
if (page < 0) page = 0;
if (page != prev) g_step_dir = page > prev ? 1 : -1; /* 高档落位后按跳页方向补邻居 */
if (g_mode == MR_MODE_WEBTOON && g_strip_ok) {
if (g_scroll) meow_scroll_stop(g_scroll);
g_woff = mr_strip_offset_for_page(&g_strip, page);
g_page = page;
} else {
g_page = page;
g_zoom_pct = 100;
g_pan_x = g_pan_y = 0;
}
slider_sync();
settle_reset(); /* 落位:停稳计时重来 */
window_update();
update_title();
cont_touch();
meow_ui_invalidate(0, 0, g_vw, g_vh);
meow_log(MEOW_LOG_INFO, "reader jump land page=%d/%d%s",
page + 1, g_count, timed_out ? " (timeout)" : "");
if (timed_out) toast("页面解码较慢,已跳到目标页");
else if (page_blocked(page))
toast(page_skipped(page) ? "目标页内存不足,已跳过" : "目标页无法显示");
}
static void jump_commit_from_slider(void)
{
if (!g_slider || g_count <= 0) return;
int page = meow_slider_get(g_slider) - 1;
g_jump_open = 0;
slider_place();
if (page < 0) page = 0;
if (page >= g_count) page = g_count - 1;
if (page == current_page()) { /* 原地:只关面板 */
meow_ui_invalidate(0, 0, g_vw, MR_TOP_BAR_H + MR_SLIDER_H + 8);
return;
}
if (slot_find(page)) { /* 已缓存:直接落位 */
jump_land(page, 0);
return;
}
mr_jump_begin(&g_jump, page, g_count, meow_ui_time_ms());
window_update(); /* 目标页进解码窗(最高优先级) */
char msg[48];
snprintf(msg, sizeof msg, "跳转到 P%d…", page + 1);
toast(msg);
meow_ui_invalidate(0, 0, g_vw, g_vh);
}
static void jump_ui_toggle(void)
{
if (g_count <= 1) { toast("只有一页,无需跳转"); return; }
g_jump_open = !g_jump_open;
if (g_jump_open) {
if (!g_slider) slider_build();
slider_sync();
}
slider_place();
meow_ui_invalidate(0, 0, g_vw, MR_TOP_BAR_H + MR_SLIDER_H + 8);
}
static void slider_scrub(int x)
{
if (!g_slider || g_count <= 1) return;
int sx = 24, sw = g_vw - 48;
if (sw < 1) sw = 1;
int v = 1 + (int)((long long)(x - sx) * (g_count - 1) / sw);
if (v < 1) v = 1;
if (v > g_count) v = g_count;
meow_slider_set(g_slider, v);
meow_ui_invalidate(0, MR_TOP_BAR_H, g_vw, MR_SLIDER_H + 8);
}
/* ============================================================ */
/* 模式切换(翻页 ⇄ 条漫) */
/* ============================================================ */
static void mode_set(int mode, int from_page)
{
if (!mr_mode_valid(mode) || mode == g_mode) return;
int page = from_page >= 0 ? from_page : current_page();
page = mr_nav_clamp_page(page, g_count);
if (page < 0) page = 0;
if (mode == MR_MODE_WEBTOON) {
if (!strip_ensure()) { toast("条漫排版失败,保持翻页"); return; }
scroll_ensure();
g_page = page;
g_woff = mr_strip_offset_for_page(&g_strip, page);
if (g_scroll) meow_scroll_stop(g_scroll);
} else {
g_page = page;
if (g_scroll) { scroll_free(); }
mr_strip_free(&g_strip);
g_strip_ok = 0;
g_woff = 0;
}
g_mode = mode;
g_zoom_pct = 100;
g_pan_x = g_pan_y = 0;
slider_sync();
settle_reset(); /* 模式/排版几何变了:停稳计时重来 */
window_update();
update_title();
cont_touch();
cont_save_now(); /* 模式切换立即记忆 */
meow_ui_invalidate(0, 0, g_vw, g_vh);
meow_log(MEOW_LOG_INFO, "reader mode=%s page=%d/%d",
mr_mode_name(g_mode), current_page() + 1, g_count);
toast(mode == MR_MODE_WEBTOON ? "条漫模式 · 连续滚动" : "翻页模式");
}
/* ============================================================ */
/* 册间衔接 / 打开册 */
/* ============================================================ */
static void open_book_reset_state(void)
{
worker_stop();
scroll_free();
mr_strip_free(&g_strip);
g_strip_ok = 0;
slots_free_all();
mr_book_close_auto(&g_book, &g_handle);
if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; }
g_gen++; /* 作废旧册的异步结果 */
g_count = 0;
g_open_err = 0;
g_open_retry_at = 0;
g_page = 0;
g_woff = 0;
g_zoom_pct = 100;
g_pan_x = g_pan_y = 0;
g_cont_dirty = 0;
g_cont_last_page = -1;
g_cont_last_mode = -1;
g_win_n = 0;
g_fail_paint = 0;
memset(g_page_fail, 0, sizeof g_page_fail);
memset(g_page_skip, 0, sizeof g_page_skip);
g_skip_ms = 0;
g_jump_open = 0;
mr_jump_cancel(&g_jump);
g_toast[0] = '\0';
g_title_page = -1;
g_sharpen_idx = -1; /* 新册:高档资格清零,停稳计时重来 */
memset(g_req_inflight, 0, sizeof g_req_inflight);
g_move_ms = meow_ui_time_ms();
g_step_dir = 1;
g_refill = 0;
free(g_meta);
g_meta = NULL;
}
/* 在目录册里按文件名定位页下标:先精确,再大小写不敏感(书库展示名与磁盘名一致,
* 兜底只为容错)。-1=没找到。 */
static int dir_book_find_index(const mr_dir_book *db, const char *name)
{
if (!db || !name || !name[0] || db->count <= 0) return -1;
for (int i = 0; i < db->count; i++)
if (db->names[i] && strcmp(db->names[i], name) == 0) return i;
for (int i = 0; i < db->count; i++)
if (db->names[i] && strcasecmp(db->names[i], name) == 0) return i;
return -1;
}
/* 打开一本册并恢复模式/起始页。start_page<0 = 末页(上一册衔接)。1=成功。
* 若 g_start_name 非空(书库点图),优先用**刚枚举出来的目录册**按 basename 定位起始页:
* 枚举源与翻页完全一致,且不受书库 MR_LIB_SCAN_MAX 截断影响。 */
static int open_book(const char *dir, int start_page, int mode)
{
if (!dir || !dir[0]) return 0;
char want_name[MR_NAME_MAX];
snprintf(want_name, sizeof want_name, "%s", g_start_name);
g_start_name[0] = '\0'; /* 只在本次打开生效 */
open_book_reset_state();
/* reader_page_enter 传入的 dir 就是 g_dir:自拷贝是 snprintf 的未定义行为,
* 其它调用(册间衔接)才需要复制。 */
if (dir != g_dir) snprintf(g_dir, sizeof g_dir, "%s", dir);
g_mode = mr_mode_valid(mode) ? mode : MR_MODE_PAGED;
int maxdim = g_vw > g_vh ? g_vw : g_vh; /* 仅作无解码计划时的兜底预算 */
/* 目录 → 目录册;.cbz/.zip → 档案册(M11)。两者同一个 mr_book 契约,
* 下面的翻页/条漫消费代码完全不区分 provider。 */
int n = mr_book_open_auto(&g_book, &g_handle, g_dir, maxdim,
reader_load_page, reader_unload_page, NULL);
if (n < 0) {
int err = errno; /* opendir/malloc 失败原因(设备排障用) */
g_open_err = 1;
worker_start();
update_title();
meow_log(MEOW_LOG_WARN, "reader open failed: %s (errno=%d %s)", g_dir, err,
strerror(err));
/* 资源类失败(内存/FD 瞬时紧张)自动重试一次:library 页已在 on_leave 停止
* 后台解码,稍后内存回收后重开即可成功;权限/路径错误不重试。 */
if ((err == ENOMEM || err == EMFILE || err == ENFILE ||
err == EAGAIN || err == EINTR) && !g_open_retry_used) {
g_open_retry_used = 1;
g_open_retry_at = meow_ui_time_ms() + 200;
}
meow_ui_invalidate(0, 0, g_vw, g_vh);
return 0;
}
g_count = n;
/* 页尺寸缓存:每页 8 字节(4096 页上限 ≈32KB),换来的是一帧内零 fopen probe。 */
g_meta = (page_meta *)calloc((size_t)g_count, sizeof *g_meta);
if (!g_meta)
meow_log(MEOW_LOG_WARN, "reader meta cache unavailable (%d pages)", g_count);
int page = start_page < 0 ? g_count - 1 : start_page;
/* 书库点图:按文件名在刚打开的目录册里定位(与翻页同一份枚举/比较器)。
* 找不到(文件已改名/删除)才退回 start_page/续读。 */
if (want_name[0] && g_handle.kind == MR_BOOK_KIND_DIR) {
int idx = dir_book_find_index(&g_handle.u.dir, want_name);
if (idx >= 0) page = idx;
}
page = mr_nav_clamp_page(page, g_count);
if (page < 0) page = 0;
if (g_mode == MR_MODE_WEBTOON) {
if (!strip_ensure()) { /* 排版失败:降级翻页,不白屏 */
meow_log(MEOW_LOG_WARN, "reader strip unavailable, fallback paged");
g_mode = MR_MODE_PAGED;
}
}
if (g_mode == MR_MODE_WEBTOON) {
scroll_ensure();
g_page = page;
g_woff = mr_strip_offset_for_page(&g_strip, page);
} else {
g_page = page;
g_woff = 0;
}
worker_start();
if (!g_worker_on && g_count > 0) {
/* 线程创建失败:退化为主线程同步解起始页(保证不是纯白屏)。 */
int ww = 0, wh = 0;
page_geometry_at(g_page, &ww, &wh);
if (ww <= 0 || wh <= 0) { ww = g_vw; wh = g_vh; }
int bw = ww, bh = wh;
plan_box_size(ww, wh, &bw, &bh);
g_plan.want_w = ww;
g_plan.want_h = wh;
g_plan.box_w = bw;
g_plan.box_h = bh;
g_plan.tier = MR_TIER_HIGH; /* 没有后台线程:直接解目标档,不分档 */
g_plan_page = g_page;
meow_img *first = mr_book_open(&g_book, g_page);
if (first) slot_put(g_page, first, ww, wh, MR_TIER_HIGH);
meow_log(MEOW_LOG_WARN, "reader worker unavailable, sync first page");
}
slider_build();
window_update(); /* 起始页优先进队 */
update_title();
cont_touch();
meow_ui_invalidate(0, 0, g_vw, g_vh);
meow_log(MEOW_LOG_INFO, "reader book=%s pages=%d mode=%s page=%d",
g_dir, g_count, mr_mode_name(g_mode), current_page() + 1);
return 1;
}
/* 册间衔接的档案册探测(M11):兄弟表 = 含图目录 ∪ 含图片页的 .cbz/.zip。 */
static int cbz_pages_probe(const char *path, void *ud)
{
(void)ud;
return mr_zip_count_pages(path);
}
static int book_switch(int delta)
{
if (delta == 0) return 0;
if (g_count <= 0) return 0;
char next[MR_PATH_MAX];
/* 根锁定:相邻册必须仍在浏览根内,绝不跳到 SD 卡挂载点以上。 */
int r = mr_book_adjacent_rooted(g_dir, delta > 0 ? 1 : -1, g_root,
cbz_pages_probe, NULL, next, (int)sizeof next);
if (r != 1) {
toast(delta > 0 ? "已是最后一册" : "已是第一册");
return 0;
}
cont_save_now(); /* 先记忆当前册 */
int mode = mr_cont_get_mode(next, g_mode); /* 相邻册自己的模式;没有则沿用当前 */
if (!open_book(next, delta > 0 ? 0 : -1, mode)) {
toast("无法打开相邻册");
return 0;
}
const char *base = strrchr(g_dir, '/');
char msg[64];
snprintf(msg, sizeof msg, "%s · %s", delta > 0 ? "下一册" : "上一册",
(base && base[1]) ? base + 1 : g_dir);
toast(msg);
return 1;
}
/* ============================================================ */
/* 跟手拖动 */
/* ============================================================ */
static void drag_paint(void)
{
uint64_t now = meow_ui_time_ms();
int gap = g_zoom_pct > 100 ? 33 : MR_PAINT_MS; /* 缩放帧要重采样:30fps 上限 */
if (now - g_last_paint < (uint64_t)gap) return;
g_last_paint = now;
invalidate_content();
}
/* 松手:过阈值且目标页已缓存 → 从当前位移吸附过去;否则回弹。 */
static void drag_finish(void)
{
if (!g_dragging) return;
g_dragging = 0;
int dx = g_drag_off;
int mode = g_drag_mode;
g_drag_off = 0;
g_drag_mode = 0;
if (mode != 1 || abs(dx) < MR_DRAG_SLOP) {
invalidate_content();
return;
}
int rtl = mr_reader_get_direction() == MR_DIR_RTL;
int delta = mr_nav_swipe_delta(rtl, dx);
int target = mr_nav_clamp_page(g_page + delta, g_count);
if (mr_nav_commit(dx, g_vw)) {
if (target == g_page) { /* 首/末页越界:进相邻册 */
book_switch(delta > 0 ? 1 : -1);
invalidate_content();
return;
}
g_flip_t0 = meow_ui_time_ms();
if (slot_find(target) && start_slide(delta, dx)) {
g_flip_hits++;
meow_log(MEOW_LOG_INFO, "reader drag flip %d->%d hit", g_page + 1, target + 1);
} else {
g_flip_misses++;
meow_log(MEOW_LOG_INFO, "reader drag flip %d->%d miss (decode pending)",
g_page + 1, target + 1);
commit_page(target);
}
} else {
g_drag_off = dx; /* start_bounce 从这里起 */
g_drag_mode = 1;
start_bounce(dx);
}
}
/* 条漫松手:到条顶/条底后再拉过阈值 → 进相邻册;否则起 fling 惯性。 */
static void webtoon_release(void)
{
int pull = g_wo_pull;
g_wo_pull = 0;
g_dragging = 0;
g_drag_mode = 0;
if (pull >= MR_BOOK_PULL && book_switch(1)) return;
if (pull <= -MR_BOOK_PULL && book_switch(-1)) return;
if (g_scroll) meow_scroll_fling(g_scroll);
cont_touch();
invalidate_content();
}
void reader_on_touch_down(int x, int y)
{
if (!g_ctrl.active || g_quitting) return;
if (g_count <= 0) return;
settle_reset(); /* 手一按下去就不算停稳:在飞的高档请求作废 */
g_touch_moved = 0; /* 每次按下都是新手势(chrome 抬手也不残留) */
if (y >= g_vh - 28) return; /* 屏底热区:系统回桌面,勿劫持 */
if (x < 24) return; /* 左缘:系统返回手势,勿劫持 */
if (g_chrome && y < MR_TOP_BAR_H)
return; /* 顶栏点击交给 tap(返回/模式/跳页/fit) */
if (g_chrome && g_jump_open && g_slider && y < MR_TOP_BAR_H + 4 + MR_SLIDER_H) {
g_dragging = 1;
g_drag_mode = 3;
g_touch_moved = 1;
slider_scrub(x);
return;
}
if (g_snap || g_bounce) return; /* 短动画中不接新手势 */
if (g_mode == MR_MODE_WEBTOON) {
g_dragging = 1;
g_drag_mode = 4;
g_wo_y0 = y;
g_wo_last_y = y;
g_wo_pull = 0;
g_tap_pending = 0; /* 条漫没有双击缩放:单击立即生效 */
if (g_scroll) meow_scroll_stop(g_scroll);
return;
}
g_dragging = 1;
g_drag_mode = pan_mode() ? 2 : 1;
g_drag_x0 = x;
g_drag_y0 = y;
g_drag_off = 0;
g_drag_pan_x0 = g_pan_x;
g_drag_pan_y0 = g_pan_y;
}
void reader_on_touch_move(int x, int y)
{
if (!g_ctrl.active || !g_dragging || g_quitting) return;
if (g_drag_mode == 3) { /* 跳页滑块拖动 */
slider_scrub(x);
return;
}
if (g_drag_mode == 4) { /* 条漫:整条滚动 */
int dy = g_wo_last_y - y; /* 手指上移 → 内容上滚 */
g_wo_last_y = y;
if (!g_touch_moved && abs(y - g_wo_y0) >= MR_DRAG_SLOP) g_touch_moved = 1;
if (!g_scroll) return;
int maxo = mr_strip_max_offset(&g_strip);
int before = g_woff;
meow_scroll_track(g_scroll, dy, meow_ui_time_ms());
int after = g_woff;
/* 到边后继续同向拉:累计「越界意图」,松手据此进相邻册。
* 到底后手指继续上移(dy>0)才是「前进」;到顶后继续下移(dy<0)是「后退」。
* 内容不足一屏(maxo==0)时两向都可拉:按手指方向累计。 */
int at_bottom = (before >= maxo && after >= maxo);
int at_top = (before <= 0 && after <= 0);
if (at_bottom && at_top) {
g_wo_pull += dy;
} else if (at_bottom) {
if (dy > 0) g_wo_pull += dy;
} else if (at_top) {
if (dy < 0) g_wo_pull += dy;
} else {
g_wo_pull = 0;
}
drag_paint();
return;
}
int dx = x - g_drag_x0;
int dy = y - g_drag_y0;
int adx = abs(dx), ady = abs(dy);
if (!g_touch_moved && (adx >= MR_DRAG_SLOP || ady >= MR_DRAG_SLOP)) {
g_touch_moved = 1;
g_tap_pending = 0; /* 变成拖动:作废未决单击 */
g_last_tap_ms = 0;
}
if (g_drag_mode == 2) { /* 放大态:单指拖拽平移 */
g_pan_x = g_drag_pan_x0 + dx;
g_pan_y = g_drag_pan_y0 + dy;
clamp_pan();
drag_paint();
return;
}
/* 竖向主导 → 放弃横滑翻页(松手后不算点击,见 reader_on_tap)。 */
if (ady > adx * 3 / 2 && ady > 16) {
g_dragging = 0;
g_drag_mode = 0;
g_drag_off = 0;
invalidate_content();
return;
}
int rtl = mr_reader_get_direction() == MR_DIR_RTL;
int delta = mr_nav_swipe_delta(rtl, dx);
int target = mr_nav_clamp_page(g_page + delta, g_count);
int off = dx;
if (target == g_page) { /* 首/末页:橡皮筋 */
int lim = g_vw / 4;
if (off > lim) off = lim;
if (off < -lim) off = -lim;
}
g_drag_off = off;
drag_paint();
}
/* ============================================================ */
/* 单击 / 双击 */
/* ============================================================ */
static void tap_topbar(int x, int y)
{
if (!g_tb) return;
int hit = meow_topappbar_hit(g_tb, x, y);
if (hit == MEOW_TOPBAR_BACK) {
cont_save_now();
meow_view_page_pop();
return;
}
if (hit < 0) return;
if (hit == g_act_mode) { mode_set(g_mode == MR_MODE_PAGED ? MR_MODE_WEBTOON
: MR_MODE_PAGED, -1); return; }
if (hit == g_act_jump) { jump_ui_toggle(); return; }
if (hit == g_act_fit) { set_fit(mr_nav_fit_next(g_fit)); return; }
}
static void tap_content(int x, int y)
{
(void)y;
if (g_count <= 0) return;
int zone = mr_nav_tap_zone(x, g_vw);
/* 当前页被预算跳过(内存不足占位)→ 中间区点按就是显式重试(任务 2:不能永久 failed)。
* 只占中间区:左右 1/3 仍必须能翻页,否则占位页会把读者困在这一页。 */
if (zone == MR_ZONE_MIDDLE) {
int f = focus_page();
if (f >= 0 && page_skipped(f) && !page_failed(f)) {
maybe_retry_skipped_focus(1);
toast("正在重试…");
window_update();
meow_ui_invalidate(0, 0, g_vw, g_vh);
return;
}
}
if (zone == MR_ZONE_MIDDLE) {
set_chrome(!g_chrome);
return;
}
if (pan_mode()) {
/* 平移模式:左右分区改为平移步进(配合拖拽,补齐两个方向)。 */
pan_by(zone == MR_ZONE_LEFT ? g_vw / 3 : -g_vw / 3, 0);
return;
}
page_flip(mr_nav_tap_delta(mr_reader_get_direction() == MR_DIR_RTL, zone));
}
static void tap_double(int x, int y)
{
if (g_count <= 0) return;
if (g_mode == MR_MODE_WEBTOON) { /* 条漫不做缩放(保持适宽/内存纪律) */
set_chrome(!g_chrome);
return;
}
if (g_zoom_pct > 100) {
g_zoom_pct = 100;
g_pan_x = g_pan_y = 0;
} else {
/* 以双击点为锚放大:该点保持在手指下(再夹到可平移范围)。
* 倍率按内存纪律夹取:缩放临时缓冲 = 目标宽×高×4,超过软上限就降档。 */
int bw = 0, bh = 0;
current_geom(&bw, &bh);
int z = MR_ZOOM_PCT;
while (z > 100 && (long long)bw * bh * 4 * z * z / 10000 > MR_ZOOM_TEMP_CAP) z -= 25;
g_zoom_pct = z;
g_pan_x = (g_vw / 2 - x) * (z - 100) / 100;
g_pan_y = (g_vh / 2 - y) * (z - 100) / 100;
clamp_pan();
}
settle_reset(); /* 缩放几何变了:重新计时(低档先上屏) */
window_update();
meow_ui_invalidate(0, 0, g_vw, g_vh);
}
static void tap_flush(void)
{
if (!g_tap_pending) return;
g_tap_pending = 0;
int x = g_tap_x, y = g_tap_y;
if (g_chrome && y >= 0 && y < MR_TOP_BAR_H) tap_topbar(x, y);
else tap_content(x, y);
}
void reader_on_tap(int x, int y)
{
if (!g_ctrl.active || g_quitting) return;
/* 跳页滑块松手:落位到目标页(目标页预解码后由 tick 落位)。 */
if (g_drag_mode == 3) {
g_dragging = 0;
g_drag_mode = 0;
g_touch_moved = 0;
jump_commit_from_slider();
return;
}
if (g_mode == MR_MODE_WEBTOON) {
if (g_dragging) {
if (g_touch_moved) {
g_touch_moved = 0;
webtoon_release();
return;
}
g_dragging = 0;
g_drag_mode = 0;
} else if (g_touch_moved) {
g_touch_moved = 0; /* 竖向拖动被 SDK 报成 TAP:忽略 */
return;
}
g_touch_moved = 0;
if (g_chrome && y >= 0 && y < MR_TOP_BAR_H) { tap_topbar(x, y); return; }
if (g_jump_open && g_slider && y < MR_TOP_BAR_H + 4 + MR_SLIDER_H) return;
set_chrome(!g_chrome);
return;
}
/* 跟手手势收尾:移动过就按拖动处理(过阈值翻页 / 否则回弹),不再当点击。 */
if (g_dragging) {
if (g_touch_moved) {
drag_finish();
g_touch_moved = 0;
return;
}
g_dragging = 0;
g_drag_mode = 0;
g_drag_off = 0;
} else if (g_touch_moved) {
g_touch_moved = 0; /* 竖向拖动被 SDK 报成 TAP:忽略 */
return;
}
g_touch_moved = 0;
/* chrome 上的点击即时生效,不参与双击判定。 */
if (g_chrome && y >= 0 && y < MR_TOP_BAR_H) {
g_tap_pending = 0;
g_last_tap_ms = 0;
tap_topbar(x, y);
return;
}
uint64_t now = meow_ui_time_ms();
if (g_last_tap_ms && now - g_last_tap_ms <= MR_DOUBLE_MS &&
abs(x - g_last_tap_x) <= 48 && abs(y - g_last_tap_y) <= 48) {
g_tap_pending = 0; /* 取消第一击的单击动作 */
g_last_tap_ms = 0;
tap_double(x, y);
return;
}
tap_flush(); /* 上一击已过期:先执行 */
g_tap_pending = 1;
g_tap_x = x;
g_tap_y = y;
g_tap_ms = now;
g_last_tap_ms = now;
g_last_tap_x = x;
g_last_tap_y = y;
if (!g_ticker) tap_flush(); /* 无节拍器兜底:不延迟单击 */
}
/* ============================================================ */
/* 节拍:收结果 + 跳页落位 + 提示超时 + 续读落盘 */
/* ============================================================ */
static void reader_tick(void)
{
if (!g_ctrl.active || g_quitting) return;
uint64_t now = meow_ui_time_ms();
if (now - g_last_tick < MR_TICK_MS) return;
g_last_tick = now;
/* 只有当落位的页真的在屏幕上(或换了几何的当前页)才重绘,预取邻居不刷屏。 */
if (results_drain() && !g_dragging) invalidate_content();
/* 高档落位后补回换清晰时为压峰值腾掉的邻居(P1-2:否则顺序翻页每次都 miss)。 */
if (g_refill) {
g_refill = 0;
if (!g_dragging) sharpen_refill();
}
/* 两级清晰度:停稳去抖(拖动/滚动期间只留低档,停稳后才补高档)。 */
tier_pump(now);
/* 打开册的瞬时资源失败:稍后自动重试一次(进入本页时已停 library 后台解码)。 */
if (g_open_err && g_open_retry_at && now >= g_open_retry_at) {
g_open_retry_at = 0;
int mode = mr_cont_get_mode(g_dir, MR_MODE_PAGED);
int page = mr_cont_get_page(g_dir, 0);
open_book(g_dir, page, mode);
return;
}
if (g_fail_paint) {
g_fail_paint = 0;
if (!g_dragging) invalidate_content();
}
if (g_tap_pending && now - g_tap_ms >= MR_DOUBLE_MS) tap_flush();
if (mr_jump_active(&g_jump)) {
int target = mr_jump_target(&g_jump);
int have = target >= 0 && (slot_find(target) != NULL || page_blocked(target));
int timed_out = 0;
if (mr_jump_poll(&g_jump, have, now, MR_JUMP_TIMEOUT_MS, &timed_out))
jump_land(target, timed_out);
}
if (g_toast[0] && now >= g_toast_until) {
g_toast[0] = '\0';
meow_ui_invalidate(0, g_vh - 140, g_vw, 140);
}
/* 条漫滚动停止后同步标题页码(滚动中不每帧更新顶栏文字)。 */
if (g_mode == MR_MODE_WEBTOON && !g_dragging &&
(!g_scroll || !meow_scroll_active(g_scroll))) {
if (current_page() != g_title_page) update_title();
}
cont_save_throttled();
}
static void ticker_update(meow_anim *a, float value, void *ctx)
{
(void)a; (void)value; (void)ctx;
reader_tick();
}
static void ticker_done(meow_anim *a, int canceled, void *ctx)
{
(void)a; (void)ctx;
g_ticker = NULL;
if (canceled || g_quitting || !g_ctrl.active) return;
meow_anim_desc d = { 0.0f, 1.0f, 0xFFFFFFFFu, MEOW_EASE_LINEAR,
ticker_update, ticker_done, NULL };
g_ticker = meow_anim_start(&d);
}
static void ticker_start(void)
{
if (g_ticker) return;
meow_anim_desc d = { 0.0f, 1.0f, 0xFFFFFFFFu, MEOW_EASE_LINEAR,
ticker_update, ticker_done, NULL };
g_ticker = meow_anim_start(&d);
if (!g_ticker) meow_log(MEOW_LOG_WARN, "reader ticker unavailable (anim pool full)");
}
/* ============================================================ */
/* 输入:滑动手势(view on_event,先于 app.c 的 on_swipe) */
/* ============================================================ */
static int reader_event(meow_view *v, int ev, int a, int b, void *ctx)
{
(void)v; (void)ctx;
if (!g_ctrl.active || g_quitting) return 0;
if (ev != MR_EV_SWIPE) return 0;
if (a == MEOW_SWIPE_LEFT) { /* 左缘返回:交给 app.c page_pop */
g_dragging = 0;
g_drag_mode = 0;
g_drag_off = 0;
g_touch_moved = 0;
g_wo_pull = 0;
cont_save_now(); /* 离开前记忆 */
return 0;
}
if (g_drag_mode == 3) { /* 滑块拖动被 SDK 报成 SWIPE:落位 */
g_dragging = 0;
g_drag_mode = 0;
g_touch_moved = 0;
jump_commit_from_slider();
return 1;
}
if (g_mode == MR_MODE_WEBTOON) {
/* 条漫:竖向滑动 = 滚动收尾(fling/册边界);横向滑动不翻页,统一消费。 */
if (g_dragging) webtoon_release();
return 1;
}
if (a == MEOW_SWIPE_RIGHT) {
if (g_dragging) { drag_finish(); return 1; }
if (g_count <= 0) return 1;
if (pan_mode()) { pan_by(b, 0); return 1; }
page_flip(mr_nav_swipe_delta(mr_reader_get_direction() == MR_DIR_RTL, b));
return 1;
}
if (a == MEOW_SWIPE_UP || a == MEOW_SWIPE_DOWN) {
if (g_dragging) { drag_finish(); return 1; }
if (pan_mode()) { /* 放大 / 原始档溢出:纵向滑动平移 */
pan_by(0, a == MEOW_SWIPE_DOWN ? b : -b);
return 1;
}
return 0; /* 未放大:纵向滑动不归本页 */
}
return 0;
}
/* ============================================================ */
/* view 生命周期 */
/* ============================================================ */
static void reader_state_reset(void)
{
slots_free_all(); /* 静态槽位初值是 idx=0/img=NULL,先清空 */
g_count = 0;
g_open_err = 0;
g_open_retry_at = 0;
g_open_retry_used = 0;
g_page = 0;
g_mode = MR_MODE_PAGED;
g_fit = MR_FIT_WIDTH;
g_zoom_pct = 100;
g_pan_x = g_pan_y = 0;
g_chrome = 1;
g_slide_delta = 0;
g_slide_from = 0;
g_anim_v = 0;
g_dragging = 0;
g_drag_mode = 0;
g_drag_off = 0;
g_touch_moved = 0;
g_wo_pull = 0;
g_woff = 0;
g_strip_ok = 0;
g_jump_open = 0;
mr_jump_cancel(&g_jump);
g_toast[0] = '\0';
g_title_page = -1;
g_cont_dirty = 0;
g_cont_last_page = -1;
g_cont_last_mode = -1;
g_win_n = 0;
g_tap_pending = 0;
g_last_tap_ms = 0;
g_quitting = 0;
/* 本册状态一律清空:g_meta 由 open_book 按新册重建(on_enter 早退也不串上一册);
* g_start_at 已在 reader_page_enter 入口取走,这里复位防残留。 */
free(g_meta);
g_meta = NULL;
g_start_at = MR_READER_START_RESUME;
memset(&g_plan, 0, sizeof g_plan);
g_plan_page = 0;
g_decode_skip = MR_SKIP_NONE;
g_decode_tier = MR_TIER_HIGH;
g_sharpen_idx = -1;
memset(g_req_inflight, 0, sizeof g_req_inflight);
g_move_ms = meow_ui_time_ms();
g_step_dir = 1;
g_refill = 0;
g_low_decodes = 0;
g_high_decodes = 0;
g_high_canceled = 0;
g_flip_hits = 0;
g_flip_misses = 0;
}
static void reader_page_enter(meow_view *v, void *ctx)
{
mr_page_ctrl *c = (mr_page_ctrl *)ctx;
c->active = 1;
g_quitting = 0;
int start_at = g_start_at; /* 显式起始页只在本次进入生效 */
g_vw = meow_ui_width();
g_vh = meow_ui_height();
v->x = 0;
v->y = 0;
v->w = g_vw;
v->h = g_vh;
meow_ui_set_fullscreen(1);
const meow_theme *th = meow_theme_get();
meow_ui_set_page_bg(th->surface);
reader_state_reset();
g_gen++; /* 作废旧册的异步结果 */
g_last_tick = 0;
g_last_paint = 0;
/* 非根页:控件在 on_enter 建 → owner=ACT_READER(§6.1 跨页泄漏防线)。 */
if (!g_tb) {
g_tb = meow_topappbar_create(0, 0, g_vw, MR_TOP_BAR_H, "");
if (g_tb) {
meow_topappbar_set_back(g_tb, 1);
g_act_mode = meow_topappbar_add_action(g_tb, "模式");
if (g_act_mode >= 0) meow_topappbar_set_item_icon(g_tb, g_act_mode, MEOW_ICON_QUEUE_MUSIC);
g_act_jump = meow_topappbar_add_action(g_tb, "跳页");
if (g_act_jump >= 0) meow_topappbar_set_item_icon(g_tb, g_act_jump, MEOW_ICON_SEARCH);
g_act_fit = meow_topappbar_add_action(g_tb, "适宽");
if (g_act_fit >= 0) meow_topappbar_set_item_icon(g_tb, g_act_fit, MEOW_ICON_REFRESH);
}
}
if (g_tb) meow_widget_set_rect(g_tb, 0, 0, g_vw, MR_TOP_BAR_H);
/* 档案册解压缓存(M11):MeowData/<pkg>/cache。library 页通常已初始化,
* 这里幂等兜底(进程重启后直接进阅读器、或交付环境只剩 reader 入口)。 */
{
const char *data = meow_config_dir();
if (data && data[0]) mr_cache_init(data);
}
/* 打开册:显式起始页(书库文件管理器模式点第 N 张)优先于续读记录;
* 否则恢复该册上次的模式与页码(无记录 → 翻页模式第 1 页)。
* 显式起始页在入口处取走(start_at 局部量),state_reset 之后仍有效。 */
int mode = mr_cont_get_mode(g_dir, MR_MODE_PAGED);
int page = start_at != MR_READER_START_RESUME ? start_at
: mr_cont_get_page(g_dir, 0);
open_book(g_dir, page, mode);
ticker_start();
log_mem("enter");
mr_nav_report();
meow_ui_paint_full();
/* 起始页解码是异步的:先给一帧文字/页码,结果在 tick 里落位。 */
reader_tick();
}
static void reader_page_leave(meow_view *v, void *ctx)
{
(void)v;
((mr_page_ctrl *)ctx)->active = 0;
g_quitting = 1;
cont_save_now(); /* 离开前把最后一页落盘 */
if (g_snap) { meow_anim_cancel(g_snap); g_snap = NULL; }
if (g_bounce) { meow_anim_cancel(g_bounce); g_bounce = NULL; }
if (g_ticker) { meow_anim_cancel(g_ticker); g_ticker = NULL; }
worker_stop(); /* 先停线程,再释放册/图 */
scroll_free();
mr_strip_free(&g_strip);
g_strip_ok = 0;
slots_free_all();
free(g_meta);
g_meta = NULL;
g_start_at = MR_READER_START_RESUME;
g_start_name[0] = '\0'; /* 未消费的文件名定位不跨页残留 */
mr_book_close_auto(&g_book, &g_handle);
if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; }
if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; }
g_act_mode = g_act_jump = g_act_fit = -1;
g_dragging = 0;
g_drag_mode = 0;
g_drag_off = 0;
g_wo_pull = 0;
g_jump_open = 0;
mr_jump_cancel(&g_jump);
g_tap_pending = 0;
g_slide_delta = 0;
g_slide_from = 0;
g_anim_v = 0;
log_mem("leave");
meow_ui_set_fullscreen(0);
}
meow_view *reader_view_create(void)
{
static const meow_view_hooks h = {
.on_enter = reader_page_enter,
.on_leave = reader_page_leave,
.on_draw = reader_draw,
.on_event = reader_event,
};
return meow_view_create(ACT_READER, MEOW_VIEW_ACTIVITY, &h, &g_ctrl);
}
/* ============================================================ */
/* 书库 → 阅读器导航(app.c / library.c 既有接缝) */
/* ============================================================ */
const char *mr_reader_dir(void)
{
return g_dir;
}
/* 打开入口共用部分:已在本页时先 pop(完整 leave/enter 生命周期),再设目标并 push。
* start_name 非空 = 按文件名定位(只对目录册),start_index 忽略。 */
static void reader_open_common(const char *path, int start_index, const char *start_name)
{
if (!path) return;
/* 已在本页(书库再次点另一张图/另一本册时可能仍是 ACT_READER):
* SDK 对同一 view 的重复 push 是 no-op(view_enter 见 ENTERED 直接返回),
* 只改 g_dir 会串到旧册状态。先真正 pop(回收当前册 + 续读落盘),
* 换目标后再 push,走完整的 leave/enter 生命周期。 */
int reopen = (meow_ui_activity_active() == ACT_READER) &&
(meow_ui_activity_depth() > 1);
if (reopen) meow_view_page_pop();
snprintf(g_dir, sizeof g_dir, "%s", path);
g_start_name[0] = '\0';
if (start_name && start_name[0]) {
/* 按文件名定位:由 open_book 在刚枚举出的目录册里找下标(不受列表截断影响)。 */
snprintf(g_start_name, sizeof g_start_name, "%s", start_name);
g_start_at = MR_READER_START_RESUME;
meow_log(MEOW_LOG_INFO, "reader open: %s (name=%s)", g_dir, g_start_name);
} else {
/* -1=续读(旧入口语义);其它负数钳到 0;上界在 open_book 里按页数钳。 */
g_start_at = start_index == MR_READER_START_RESUME ? MR_READER_START_RESUME
: (start_index < 0 ? 0 : start_index);
meow_log(MEOW_LOG_INFO, "reader open: %s (start=%d)", g_dir, g_start_at);
}
meow_view_page_push(ACT_READER);
}
void mr_reader_open_dir_at(const char *path, int start_index)
{
reader_open_common(path, start_index, NULL);
}
void mr_reader_open_dir_at_name(const char *dir, const char *name)
{
reader_open_common(dir, MR_READER_START_RESUME, name);
}
void mr_reader_open_dir(const char *path)
{
mr_reader_open_dir_at(path, MR_READER_START_RESUME);
}
#ifdef MR_READER_HOST_TEST
/* 宿主集成测试观察口(不改变任何状态)。 */
int mr_reader_ui_mode(void) { return g_mode; }
int mr_reader_ui_page(void) { return current_page(); }
int mr_reader_ui_count(void) { return g_count; }
int mr_reader_ui_open_err(void) { return g_open_err; }
int mr_reader_ui_jump_open(void) { return g_jump_open; }
int mr_reader_ui_jump_pending(void) { return mr_jump_active(&g_jump); }
long mr_reader_ui_resident_kb(void) { return resident_bytes() / 1024; }
int mr_reader_ui_failed_pages(void)
{
int n = 0;
for (int i = 0; i < g_count && i < MR_BOOK_MAX_PAGES; i++)
if (page_failed(i)) n++;
return n;
}
int mr_reader_ui_skipped_pages(void)
{
int n = 0;
for (int i = 0; i < g_count && i < MR_BOOK_MAX_PAGES; i++)
if (page_skipped(i) && !page_failed(i)) n++;
return n;
}
void mr_reader_ui_test_set_mem(long free_kb, long avail_kb)
{
g_mem_free_override = free_kb;
g_mem_avail_override = avail_kb;
}
/* 峰值模型自检(只读):用真代码的模型算某个文件的预估峰值,供宿主测试与实测对照。
* 走 page_source 的唯一实现(memfree/mavail 传 -1 = 只算模型,不注入门控读数)。 */
long long mr_reader_ui_decode_peak_kb(const char *path, int step, int box_w, int box_h)
{
mr_img_info in;
mr_decode_decision dec;
if (!path || !mr_probe_info(path, &in)) return 0;
reader_estimate_peak(&in, step, box_w, box_h, -1, -1, &dec);
return dec.peak_bytes / 1024;
}
long mr_reader_ui_peak_cap_kb(void) { return (long)(reader_peak_budget_bytes(-1) / 1024); }
long mr_reader_ui_peak_budget_kb(void) { return (long)(reader_peak_budget_bytes(mem_free_kb()) / 1024); }
long mr_reader_ui_resident_cap_kb(void) { return (long)(MR_RESIDENT_CAP / 1024); }
int mr_reader_ui_blit_draws(void) { return g_blit_draws; }
int mr_reader_ui_scaled_draws(void) { return g_scaled_draws; }
int mr_reader_ui_decode_count(void) { return g_decode_count; }
int mr_reader_ui_fit(void) { return g_fit; }
int mr_reader_ui_chrome(void) { return g_chrome; }
/* M31 两级清晰度观察口:焦点页当前档位(-1=无图/0=低档/1=目标档位)、各档解码次数、
* 被取消的高档请求数、当前允许升级的页(-1=拖动/未停稳/无需升级)。 */
int mr_reader_ui_focus_tier(void)
{
page_slot *s = slot_find(focus_page());
if (!s) return -1;
return s->tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH;
}
int mr_reader_ui_slot_tier(int idx)
{
page_slot *s = slot_find(idx);
if (!s) return -1;
return s->tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH;
}
long mr_reader_ui_slot_kb(int idx)
{
page_slot *s = slot_find(idx);
if (!s) return 0;
return (long)s->src_w * s->src_h * 4 / 1024;
}
int mr_reader_ui_low_decodes(void) { return g_low_decodes; }
int mr_reader_ui_high_decodes(void) { return g_high_decodes; }
int mr_reader_ui_high_canceled(void) { return g_high_canceled; }
int mr_reader_ui_sharpen_idx(void) { return g_sharpen_idx; }
/* 翻页命中/未命中:目标页已在槽(= 预取生效,翻页不用等解码)/ 还没解好。 */
int mr_reader_ui_flip_hits(void) { return g_flip_hits; }
int mr_reader_ui_flip_misses(void) { return g_flip_misses; }
/* 视图位置(M31 r2 评审 P2-2:换清晰不得重置缩放/平移/条漫滚动位置)。 */
int mr_reader_ui_zoom_pct(void) { return g_zoom_pct; }
int mr_reader_ui_pan_x(void) { return g_pan_x; }
int mr_reader_ui_pan_y(void) { return g_pan_y; }
int mr_reader_ui_woff(void) { return g_woff; }
/* 宿主测试改写「停稳」窗口:0=立刻允许高档(同步验证落位),很大=冻结高档(验证低档先落位)。 */
void mr_reader_ui_test_set_sharpen_ms(int ms)
{
g_sharpen_ms = ms < 0 ? 0 : ms;
}
/* 宿主测试:绕过停稳判定强行排一个「换清晰」请求,用于验证拖动中会被取消。 */
void mr_reader_ui_test_queue_high(int idx)
{
if (idx < 0 || idx >= g_count) return;
queue_page_tier(idx, MR_TIER_HIGH, 1);
}
/* 直接切模式(避开顶栏 tap 命中时序):与顶栏动作走同一个 mode_set。 */
void mr_reader_test_toggle_mode(void)
{
mode_set(g_mode == MR_MODE_PAGED ? MR_MODE_WEBTOON : MR_MODE_PAGED, -1);
}
void mr_reader_test_set_root(const char *root)
{
if (root && root[0]) snprintf(g_root, sizeof g_root, "%s", root);
}
const char *mr_reader_test_root(void) { return g_root; }
#endif