diff --git a/patch_v2/ncm_app/REPORT.md b/patch_v2/ncm_app/REPORT.md index 43b39df..a0c6dae 100644 --- a/patch_v2/ncm_app/REPORT.md +++ b/patch_v2/ncm_app/REPORT.md @@ -926,3 +926,127 @@ start → home: playlists rc=0 count=120 uid=408996274 → exit - **`al.picUrl` 封面保留**:song_detail 经 `parse_songs_array` 解析出 `al.picUrl`,§12.1 黑胶真专辑封面功能不退。 - **构建**:`build_ncm.sh` mipsel 自有代码 `-Wall -Wextra` 0 warning(本批改动文件逐文件复核无告警);vendor 豁免。 - **未动 sdk/、meow_launcherd/、vendor/,未 commit/push/刷机,未动设备系统/恢复区**;部署留用户后续验收。 + + +--- + +## 14. 三期第一批:歌单真分页 + 专辑封面 ncm_cache 化 + 歌词后台加载补锁(2026-08-31,agent-239) + +> 范围:仅 `patch_v2/ncm_app/`(含 build 脚本与 REPORT 本文件)。未动 `sdk/`(主会话在改 `meow_ui_ime.c`)、 +> `meow_launcherd/`、`vendor/`、`core/`。风控纪律:本批真实 NCM 请求 **0 次**(全部离线 stub/宿主导出/已落盘样本)。 +> 部署/验收留用户(真机验收点见 §14.7)。 + +### 14.1 歌单分页(detail 全量 trackIds + song_detail 分批,取消 256 硬截断,分页驱动) + +- **背景**:§13.1 真机确认 `/api/playlist/track/all`(eapi/weapi、v6/无v6)全 404,§12.3 的“服务端分页”不可行; + §13 回退到 detail(`n=1`)+ song_detail 但 `NCM_SONG_MAX=256` 硬截断、一次拉满(首屏 34.7s)。本期把 + “256 截断 + 一次拉满”改为“**detail 全量 trackIds 驱动 + song_detail 分批 + 触底逐页加载**”。 +- `app/logic/appstate.c` `appstate_load_playlist_songs_page(idx,offset,limit)` 重写: + - `offset==0`(重头):detail(`n=1`) 取全量 trackIds 入 `song_ids[]`(≤ `NCM_SONG_MAX`),记 `track_ids_count`; + `song_count`/`songs[]` 清空;只载**第 0 页**(song_detail 前 `limit` 首,其余待触底再拉,不冻 UI、不一次拉满)。 + - `offset>0`(触底追加):从 `song_ids[offset..]` 载 song_detail,追加写 `songs[offset..]`, + `song_count = offset + 本页实际数`;**失败返回不修改 song_count**(保留已载页)。`offset>=track_ids_count` 或 + `offset>=NCM_SONG_MAX` 时无操作返回。 + - `track_ids_count` 为“是否还有下一页”唯一判据(`song_count < track_ids_count`)。 +- `app/ui/playlist.c`:`playlist_worker` 按页(`NCM_SONG_BATCH`)调 `appstate_call`;`g_pl_more = + (rc==NCM_OK) && song_countal_pic[0] ? s->al_pic : g_st.cover_url`(§12.1),**歌单列表/详情头块封面保持现状**(`cover_get()`,未改)。 +- `app/logic/cover.c` 封面图改走 **ncm_cache**(LRU 口径沿用 ncm_cache):键 = djb2(封面 URL) → 文件 + `song_cov.jpg`(与 mp3 `song_.mp3` 同池,`ncm_cache_lookup`/`commit` 刷新 mtime=LRU)。 + worker 流程:① 缓存命中直接解码(免下载、二次进入离线可看);② 未命中 → 下载解码 → `ncm_cache_open/write/commit` + 落盘;仍写 `cover_current.jpg`(`save_cover_raw`)供 launcher 媒体卡片。 +- **顺带修真 bug**:旧 `save_cover_raw` 在已持 `g_cm` 的临界区内再 `pthread_mutex_lock(&g_cm)`(非递归锁自锁死)→ + cover worker 永久卡死、`g_gen` 永不 +1 → 黑胶封面**永不刷新**。改为“调用方持有 `g_cm`”(不再二次取锁); + 封面写盘缓冲扩到 `NCM_CACHE_DIR_MAX+32`(顺带消宿主 glibc fortify `-Wformat-truncation` 告警,mipsel musl 本就无)。 + +### 14.3 歌词后台加载(确认已异步;补数据竞争锁) + +- 歌词后台加载 §11(`player_manager.c` `lyric_worker`:轮询当前曲 → 异步 `ncm_api_lyric` → `lyric_parse`, + UI 不阻塞;`lyric_state()` 判「加载中/无词」)**已落地**,本批未改后台线程模型(确实不阻塞 UI)。 +- 补 `app/logic/lyric.c` **数据竞争锁** `g_lyric_mu`:写侧 `lyric_parse` 整段持锁(清空用 `lyric_clear_nolock` + 避免二次取锁自锁死);读侧 `lyric_count`/`lyric_line_at`/`lyric_line_text`/`lyric_line_tl` 每次取锁。 + 消除 UI 线程(player.c 歌词区)与 IPC 线程(ipc_media.c `lyric_count`)同 lyric worker 间的数据竞争 + (早前为“良性竞态”§10.9 FM4;本期按“禁止数据竞争”要求收紧)。 +- 与现有 `g_play.mu` 同模式(函数级锁、无嵌套)。已知残余:worker 读 `g_st.songs[idx].id` 无锁—— + `songs[]` 写读竞态(§10.9 FM4 接受,与播放线程同源),本期未扩范围。 + +### 14.4 验证记录(0 真实 NCM 请求) + +| 项 | 方式 | 结果 | +|---|---|---| +| 歌单分页状态机 | `bench/pagination_sim.c` 离线 stub(0 网络)驱动**真实** `appstate_load_playlist_songs_page` | **全 PASS**:130 首读全程(1 detail + 2 song_detail 页,`song_count=130/track_ids_count=130`,末首含 al_pic);5191 首封顶 `NCM_SONG_MAX`(1000,10 页,越界无操作);空歌单 0;越过尾部/上限再 load 均无副作用 | +| mipsel 构建 | `bash build_ncm.sh`(自有代码 `-Wall -Wextra`) | **0 warning**;`text 842820 / data 856 / bss 2052068`(bss +~1MB 来自 NCM_SONG_MAX 1000) | +| 宿主离屏渲染 | `build_ncm_host.sh` + `MEOW_UI_DUMP`(mock,0 网络) | home/playlist/player/lands 渲染正常(歌单 6 行 + footer `6/128`、黑胶 + 歌词当前行红色高亮),无崩溃 | +| 宿主链接 | `build_ncm_host.sh` | 补 `meow_pinyin.o`(主会话 229 拼音 IME 引入 `py_predict`,旧 host SDK_SRC 漏编 → 链接断);cover.c 截断告警已消 | + +### 14.5 md5(本批改动文件 + 产物) + +``` +b34bc1ed2c3539f1ca80a005884833cd ncm # mipsel 静态 App(新) +1a2327e0ccd352d11bea88ebd35df549 ncm_host # 宿主离屏调试二进制 +0b0a4dd5e208d7b5eb504befb374a29e app/logic/appstate.c +6d66eed99c556e301498c5823b43c294 app/logic/cover.c +ace0def0e8c3ec991013b0675957f269 app/logic/lyric.c +1a1871032c2ecae7a1cc80c7aa00381a app/ncm_app_internal.h +ab87bd5f74b82fd3d62c49a16a4c9fe5 app/ui/playlist.c +c73366373b3a372d96b2218e528c6381 build_ncm_host.sh +141e9098a7cabeade9aa6b442ba20910 bench/pagination_sim.c +``` + +### 14.6 出入清单(只改 `patch_v2/ncm_app/`) + +``` +修改: + app/logic/appstate.c # 歌单分页:offset=0 重建 trackIds 索引+载第0页;offset>0 追加;track_ids_count + app/ui/playlist.c # 触底逐页加载 + g_pl_more 判据 + 加载/失败/空态 + 点按重试 + 追加失败保留已载 + app/ncm_app_internal.h # NCM_SONG_MAX 1000 / NCM_SONG_BATCH 100 / 删 NCM_PLAYLIST_PAGE + track_ids_count + app/logic/cover.c # 封面走 ncm_cache(LRU) + 修 save_cover_raw 自锁死 + 缓冲扩容消截断告警 + app/logic/lyric.c # 加 g_lyric_mu(写 parse 整段持锁、读逐次持锁)消除数据竞争 + build_ncm_host.sh # host SDK_SRC 补 meow_pinyin(拼音 IME py_predict 漏编→链接断) + ncm / ncm_host # 重编产物 +新增: + bench/pagination_sim.c # 歌单分页离线状态机回归(stub 网络端点,0 请求) + REPORT.md # 本批记录(§14) +(未动 sdk/、meow_launcherd/、vendor/、core/;未 commit/push/刷机;未动设备系统/恢复区) +``` + +### 14.7 诚实出入 / 已知限制 + +- **真机未部署/未验**(风控:0 新增真实请求)。部署链路同 §13.5(`scp ncm root@:/usr/data/mnt/sd_0/MeowAPPs/dev.player.ncm/ncm.new` + → `ssh root@ '. /etc/profile; mv ...ncm.new ...ncm'`,先 `.new` 再 `mv` 防 ETXTBSY)。真机验收点: + ① 小歌单全程可读;大歌单(如 5191)下滑触底逐页加载到 1000 封顶(footer `已载/总数`),不冻 UI; + ② 黑胶显示真实专辑封面(首次下载→ncm_cache 落盘,二次进入免下载;第三方封面 `http://` 若握手失败回落歌单封面, + 为 §12.8 既有“http 封面走 TLS”旧疾,本批未改 core 规避);③ 歌词后台加载不卡 UI、切歌刷新、下/上一首顺延。 +- **大歌单仍有内存上限(NCM_SONG_MAX=1000)**:这是 14MB 硬约束下的取舍(每 +100 首 ≈ +132KB)。 + 若要全量 5000+ 首,需:a) 歌曲对象瘦身(`al_pic[512]` 每首占大头,可拆到按需加载),或 b) 列表滑动窗口 + (`meow_listview` 需 O(1) `src2(idx)` 重映射,复杂度高)。footer「已载/总数」已向用户标明进度。 +- **`ncm_cache_evict` 生产路径未显式触发超限淘汰**(§10.9 既有遗留:evict 仅 test 调用;song/cover commit 后不自动 + 触发)。封面走 ncm_cache 同池后同样受该路径约束——**超限淘汰非本批引入**,本批也未扩大行为(不主动加 evict 调用, + 避免改已验证的缓存写入路径)。 +- **歌词锁残余**:读侧函数取锁后返回存量指针(`s_txt/s_tl` 静态数组),解锁后可能被下一次 `lyric_parse` 覆盖; + 写入仅发生在切歌瞬间(一次/歌),本地读在一两帧内完成,峰值可能显示一帧旧/空行(与 §10.9 FM4 同接受)。 + 严格消除需调用方持锁拷贝(本批未做,接口需改,避免扩大 diff)。 +- **host 构建有 SDK `meow_ui_vol.c`「system 返回值忽略」告警**(SDK 源,非本 App;mipsel musl 无)。本 App 自有代码 0 warning。 +- **分页触底加载与误触/点按为逻辑推断**(无法经 SSH 驱动真实触摸/滚动);误触修复(§13.3)未在分页下回归真机。 + 建议真机快速下滑到底验证逐页加载、点按重试、追加失败保留已载。 + +### 14.8 数值权衡(供后续微调) + +| 常量 | 旧值 | 新值 | 说明 | +|---|---|---|---| +| `NCM_SONG_MAX` | 256 | 1000 | 单页渲染上限(分页驱动),歌曲数组 ~1.32KB/首 → +100 ≈ +132KB bss | +| `NCM_SONG_BATCH` | 64 | 100 | song_detail 每批(≤500 安全窗内保守 100;`clist[4096]` 装 100 首 ≈2KB) | +| `NCM_PLAYLIST_PAGE` | 50 | 删除 | track/all 服务端分页遗留(该端点 404),改由 `NCM_SONG_BATCH` 承担页粒度 | +| `g_st.track_ids_count` | — | 新增 | detail 取回 trackIds 总数,驱动「还有下一页」判据 | diff --git a/patch_v2/ncm_app/app/logic/appstate.c b/patch_v2/ncm_app/app/logic/appstate.c index 4de3a66..7e8b643 100644 --- a/patch_v2/ncm_app/app/logic/appstate.c +++ b/patch_v2/ncm_app/app/logic/appstate.c @@ -126,34 +126,44 @@ int appstate_load_playlist_songs_page(int idx, int offset, int limit) if (limit <= 0 || limit > NCM_SONG_BATCH) limit = NCM_SONG_BATCH; if (offset < 0) offset = 0; - /* 二期曾改走 /api/playlist/track/all 服务端分页(REPORT §12.3),但真机实测该端点 - * (eapi/weapi、v6/无v6 四种路由)全部返回 code 404「接口未找到」——NCM 服务端无此路由, - * 故回退到 REPORT §10.11 已验证的 /api/v6/playlist/detail(trackIds) + /api/v3/song/detail 分批。 - * detail 一次取回前 NCM_SONG_MAX 首 trackIds;song_detail 分批取歌曲对象(含 al.picUrl)。 - * offset==0 重头清空并整批载入;offset>0(触底“加页”)时 detail 已一次取满,无独立分页, - * 直接返回 NCM_OK(歌单数 ≤NCM_SONG_MAX 时已全部在列,song_count 不变)。 */ - if (offset != 0) return NCM_OK; + /* REPORT §13.1:/api/playlist/track/all 服务端分页端点真机全 404(NCM 无此路由),弃用。 + * 回到 §10.11 已验证路径并做**真分页**:detail(n=1)取【全量】trackIds 入 song_ids[], + * song_detail 按 ≤NCM_SONG_BATCH 分批拉歌曲对象(含 al.picUrl),offset 为「下一页」起始; + * 列表触底驱动 offset 前进,song_count 逐页累加(不再一次性拉满,大歌单不冻 UI, + * 也不把「第 N 页之后的歌」挡在 256 之外——NCM_SONG_MAX 上限提升到 1000,见 §14)。 */ - memset(g_st.song_ids, 0, sizeof g_st.song_ids); - int idn = 0; - int rc = ncm_api_playlist_detail(g_st.ctx.device_id, g_st.ctx.cookie, pl->id, - NULL, 0, NULL, 0, - g_st.song_ids, NCM_SONG_MAX, &idn); - if (rc != NCM_OK) { g_st.song_count = 0; return rc; } - - memset(g_st.songs, 0, sizeof g_st.songs); - int outn = 0; - for (int off = 0; off < idn; off += NCM_SONG_BATCH) { - int batch = idn - off; - if (batch > NCM_SONG_BATCH) batch = NCM_SONG_BATCH; - int bn = 0; - rc = ncm_api_song_detail(g_st.ctx.device_id, g_st.ctx.cookie, &g_st.song_ids[off], - batch, &g_st.songs[off], NCM_SONG_MAX - off, &bn); - if (rc != NCM_OK) break; - outn += bn; - if (bn < batch) break; + if (offset == 0) { + /* 重头:重建 trackIds 索引 + 清空已物化歌曲(song_count 无论成败都归零)。 */ + memset(g_st.song_ids, 0, sizeof g_st.song_ids); + memset(g_st.songs, 0, sizeof g_st.songs); + g_st.song_count = 0; + g_st.track_ids_count = 0; + int idn = 0; + int rc = ncm_api_playlist_detail(g_st.ctx.device_id, g_st.ctx.cookie, pl->id, + NULL, 0, NULL, 0, + g_st.song_ids, NCM_SONG_MAX, &idn); + if (rc != NCM_OK) return rc; + g_st.track_ids_count = idn; + /* 首屏只需第 0 页:song_detail 只拉前 limit 首,其余待滚动触底再拉(分页驱动)。 */ + } else if (g_st.track_ids_count == 0) { + /* 防御:offset>0 但没有索引(不应发生;主路径先走 offset=0)。 */ + return NCM_ERR_ARG; } - g_st.song_count = outn; + + if (offset >= g_st.track_ids_count) return NCM_OK; /* 已到尾部/上限 */ + if (offset >= NCM_SONG_MAX) return NCM_OK; /* 内存上限:不再物化更多 */ + + int can = g_st.track_ids_count - offset; + int room = NCM_SONG_MAX - offset; + if (can > limit) can = limit; /* 每页 ≤ limit(保守 100) */ + if (can > room) can = room; /* 不越界 song_ids[]/songs[] */ + + int bn = 0; + int rc = ncm_api_song_detail(g_st.ctx.device_id, g_st.ctx.cookie, &g_st.song_ids[offset], + can, &g_st.songs[offset], NCM_SONG_MAX - offset, &bn); + if (rc != NCM_OK) return rc; /* 失败:song_count 不变(保留已载页) */ + + g_st.song_count = offset + bn; /* 成功:累加本页 */ appstate_apply_playlist_cover(idx); return NCM_OK; } @@ -161,15 +171,16 @@ int appstate_load_playlist_songs_page(int idx, int offset, int limit) int appstate_load_playlist_songs(int idx) { /* 兼容入口:整批载入(内部按页循环到 NCM_SONG_MAX 或歌单尾部)。 */ - int rc, loaded = 0; - do { - rc = appstate_load_playlist_songs_page(idx, loaded, NCM_PLAYLIST_PAGE); + int rc = appstate_load_playlist_songs_page(idx, 0, NCM_SONG_BATCH); + if (rc != NCM_OK) return rc; + int off = g_st.song_count; + while (off < g_st.track_ids_count && off < NCM_SONG_MAX) { + rc = appstate_load_playlist_songs_page(idx, off, NCM_SONG_BATCH); if (rc != NCM_OK) return rc; - int before = loaded; - loaded = g_st.song_count; - if (loaded - before < NCM_PLAYLIST_PAGE) break; /* 尾部:已载完 */ - if (loaded >= NCM_SONG_MAX) break; - } while (1); + int nxt = g_st.song_count; + if (nxt <= off) break; /* 无进展:防死循环 */ + off = nxt; + } return NCM_OK; } diff --git a/patch_v2/ncm_app/app/logic/cover.c b/patch_v2/ncm_app/app/logic/cover.c index d116046..4e423d5 100644 --- a/patch_v2/ncm_app/app/logic/cover.c +++ b/patch_v2/ncm_app/app/logic/cover.c @@ -10,6 +10,7 @@ #include #include #include +#include static pthread_mutex_t g_cm = PTHREAD_MUTEX_INITIALIZER; static meow_img *g_cover_img; @@ -42,18 +43,39 @@ static meow_img *decode_cover(const char *url, size_t *out_len) return meow_img_load_mem((const void *)resp.body, (int)resp.body_len); } +/* 封面缓存键:URL 的 djb2 哈希(稳定性由 URL 决定——同一专辑封面 URL 名即可命中)。 + * 走 ncm_cache(LRU 口径沿用 ncm_cache,见 REPORT §14.2):键进 ncm_cache_path => 文件 + * `song_cov.jpg`,与 mp3(song_.mp3)同池,lookup/commit 刷新 mtime(LRU)。 */ +static void cover_cache_id(const char *url, char *out, size_t out_sz) +{ + unsigned long h = 5381; + for (const char *p = url; *p; p++) h = ((h << 5) + h) + (unsigned char)*p; + snprintf(out, out_sz, "cov%lx", h); +} + +/* 读整文件到 buf(< cap),成功返回 1,写入 *len。 */ +static int read_file_into_buf(const char *path, unsigned char *buf, size_t cap, size_t *len) +{ + FILE *f = fopen(path, "rb"); + if (!f) return 0; + size_t n = fread(buf, 1, cap, f); + fclose(f); + if (n == 0) return 0; + if (len) *len = n; + return 1; +} + +/* 写 cover_current.jpg 供 launcher 媒体卡片读(兼容保留)。调用方须已持有 g_cm + * (早前版本在此内再 lock g_cm → 非递归锁自锁死——cover worker 永久卡死、封面永不出现)。 */ static void save_cover_raw(const char *url, size_t len) { if (!url || len == 0) return; if (g_st.cache_dir[0]) { - char path[512]; + char path[NCM_CACHE_DIR_MAX + 32]; /* >512:宿主 glibc fortify 对 %s 展开宽证明可放 */ snprintf(path, sizeof path, "%s/cover_current.jpg", g_st.cache_dir); ncm_store_write_file(path, g_buf, len); - pthread_mutex_lock(&g_cm); ncm_strlcpy(g_cover_path, path, sizeof g_cover_path); - pthread_mutex_unlock(&g_cm); } - (void)url; } static void *cover_worker(void *arg) @@ -66,9 +88,31 @@ static void *cover_worker(void *arg) ncm_strlcpy(url, g_tgt_url, sizeof url); pthread_mutex_unlock(&g_cm); + char key[32]; + cover_cache_id(url, key, sizeof key); size_t blen = 0; - meow_img *img = decode_cover(url, &blen); - if (!img) { usleep(500 * 1000); continue; } + meow_img *img = NULL; + + /* ① 缓存命中直接解码(免下载、离线可看;LRU:lookup 刷新 mtime) */ + char cpath[512]; + if (g_st.ctx.cache.dir[0] && ncm_cache_lookup(&g_st.ctx.cache, key, ".jpg", + cpath, sizeof cpath) == NCM_OK && + read_file_into_buf(cpath, g_buf, sizeof g_buf, &blen)) { + img = meow_img_load_mem(g_buf, (int)blen); + } + /* ② 未命中 → 下载解码;成功后落进 ncm_cache(LRU 池,超限由 ncm_cache_evict 淘汰) */ + if (!img) { + blen = 0; + img = decode_cover(url, &blen); + if (!img) { usleep(500 * 1000); continue; } + if (g_st.ctx.cache.dir[0]) { + ncm_cache_writer_t *w = NULL; + if (ncm_cache_open(&g_st.ctx.cache, key, ".jpg", &w) == NCM_OK) { + ncm_cache_write(w, g_buf, blen); + ncm_cache_commit(w); + } + } + } pthread_mutex_lock(&g_cm); /* 目标仍是 url 才换入(期间未提交更早) */ diff --git a/patch_v2/ncm_app/app/logic/lyric.c b/patch_v2/ncm_app/app/logic/lyric.c index da7dbbe..3e75286 100644 --- a/patch_v2/ncm_app/app/logic/lyric.c +++ b/patch_v2/ncm_app/app/logic/lyric.c @@ -10,11 +10,24 @@ #include #include +/* 歌词数据由 lyric_worker(player_manager.c,后台线程)写(lyric_parse/lyric_clear)而 UI 线程 + * 与 IPC 线程读(lyric_count/lyric_line_at/lyric_line_text/lyric_line_tl)。三期第一批(REPORT §14.3) + * 补一把锁消除**数据竞争**(早前为良性竞态 §10.9 FM4;本节要求禁止数据竞争)。锁粒度=函数级, + * 写侧在 lyric_parse 整段持锁、读侧每次访问持锁,无嵌套(清空用 nolock 版,避免二次取锁自锁死)。 */ #define LRC_MAX_TEXT 192 static long s_t[NCM_LYRIC_LINES]; static char s_txt[NCM_LYRIC_LINES][LRC_MAX_TEXT]; static char s_tl[NCM_LYRIC_LINES][LRC_MAX_TEXT]; static int s_n; +static pthread_mutex_t g_lyric_mu = PTHREAD_MUTEX_INITIALIZER; + +static void lyric_clear_nolock(void) +{ + s_n = 0; + memset(s_t, 0, sizeof(s_t)); + memset(s_txt, 0, sizeof(s_txt)); + memset(s_tl, 0, sizeof(s_tl)); +} /* 复制一行歌词正文 n 字节到 dst,把「字面反斜杠 n/r」序列(服务器用 \n 把多行文本塞进一行, * 字面 \n 渲染成豆腐块/凌乱)替换为空格;顺带把 \r\n / \n\r 折叠为单空格。有界 ≤max。 */ @@ -104,57 +117,74 @@ static int cmp_lines(const void *a, const void *b) int lyric_parse(const char *lrc, const char *tlrc) { - lyric_clear(); + pthread_mutex_lock(&g_lyric_mu); + lyric_clear_nolock(); + int *idx = NULL; + int result = 0; lrc_parse(lrc, 0); lrc_parse(tlrc, 1); /* 按时间排序(用索引数组) */ - int *idx = (int *)malloc(sizeof(int) * (size_t)(s_n ? s_n : 1)); - if (!idx) return s_n; - for (int i = 0; i < s_n; i++) idx[i] = i; - qsort(idx, (size_t)s_n, sizeof(int), cmp_lines); - long ttmp[NCM_LYRIC_LINES]; - char txt[NCM_LYRIC_LINES][LRC_MAX_TEXT]; - char tl[NCM_LYRIC_LINES][LRC_MAX_TEXT]; - memcpy(ttmp, s_t, sizeof(s_t)); - memcpy(txt, s_txt, sizeof(s_txt)); - memcpy(tl, s_tl, sizeof(s_tl)); - for (int i = 0; i < s_n; i++) { - s_t[i] = ttmp[idx[i]]; - memcpy(s_txt[i], txt[idx[i]], LRC_MAX_TEXT); - memcpy(s_tl[i], tl[idx[i]], LRC_MAX_TEXT); + idx = (int *)malloc(sizeof(int) * (size_t)(s_n ? s_n : 1)); + if (idx) { + for (int i = 0; i < s_n; i++) idx[i] = i; + qsort(idx, (size_t)s_n, sizeof(int), cmp_lines); + long ttmp[NCM_LYRIC_LINES]; + char txt[NCM_LYRIC_LINES][LRC_MAX_TEXT]; + char tl[NCM_LYRIC_LINES][LRC_MAX_TEXT]; + memcpy(ttmp, s_t, sizeof(s_t)); + memcpy(txt, s_txt, sizeof(s_txt)); + memcpy(tl, s_tl, sizeof(s_tl)); + for (int i = 0; i < s_n; i++) { + s_t[i] = ttmp[idx[i]]; + memcpy(s_txt[i], txt[idx[i]], LRC_MAX_TEXT); + memcpy(s_tl[i], tl[idx[i]], LRC_MAX_TEXT); + } + free(idx); } - free(idx); - return s_n; + result = s_n; + pthread_mutex_unlock(&g_lyric_mu); + return result; } int lyric_line_at(int64_t ms) { + pthread_mutex_lock(&g_lyric_mu); int line = 0; for (int i = 0; i < s_n; i++) { if (s_t[i] <= ms) line = i; else break; } + pthread_mutex_unlock(&g_lyric_mu); return line; } const char *lyric_line_text(int i) { - if (i < 0 || i >= s_n) return ""; - return s_txt[i]; + pthread_mutex_lock(&g_lyric_mu); + const char *p = (i < 0 || i >= s_n) ? "" : s_txt[i]; + pthread_mutex_unlock(&g_lyric_mu); + return p; } const char *lyric_line_tl(int i) { - if (i < 0 || i >= s_n) return ""; - return s_tl[i]; + pthread_mutex_lock(&g_lyric_mu); + const char *p = (i < 0 || i >= s_n) ? "" : s_tl[i]; + pthread_mutex_unlock(&g_lyric_mu); + return p; } -int lyric_count(void) { return s_n; } +int lyric_count(void) +{ + pthread_mutex_lock(&g_lyric_mu); + int n = s_n; + pthread_mutex_unlock(&g_lyric_mu); + return n; +} void lyric_clear(void) { - s_n = 0; - memset(s_t, 0, sizeof(s_t)); - memset(s_txt, 0, sizeof(s_txt)); - memset(s_tl, 0, sizeof(s_tl)); + pthread_mutex_lock(&g_lyric_mu); + lyric_clear_nolock(); + pthread_mutex_unlock(&g_lyric_mu); } diff --git a/patch_v2/ncm_app/app/ncm_app_internal.h b/patch_v2/ncm_app/app/ncm_app_internal.h index f56bcc0..de659a8 100644 --- a/patch_v2/ncm_app/app/ncm_app_internal.h +++ b/patch_v2/ncm_app/app/ncm_app_internal.h @@ -39,11 +39,17 @@ #define APP_OP_W 48 /* 行右侧操作区触达宽 */ #define APP_FOOTER_H 26 -/* ---- 数据规模(内存纪律:歌单/歌曲大列表虚拟化显示,数组有界)---- */ +/* ---- 数据规模(内存纪律:歌单/歌曲大列表虚拟化显示,数组有界;硬约束 R3P2 ~14MB)---- */ #define NCM_PLAYLIST_MAX 120 /* 我的歌单上限 */ -#define NCM_SONG_MAX 256 /* 单个歌单曲目上限(大歌单截断前 256 条) */ -#define NCM_SONG_BATCH 64 /* song_detail 分批每批条数 */ -#define NCM_PLAYLIST_PAGE 50 /* 歌单曲目服务端分页每页条数(/api/playlist/track/all) */ +/* 单个歌单曲目上限 = 分页驱动下的「单页渲染上限」(REPORT §14,三期第一批): + * detail(/v6/playlist/detail, n=1)取回全量 trackIds 入 song_ids[](≤ NCM_SONG_MAX); + * song_detail 按 NCM_SONG_BATCH 分批拉歌词对象,列表触底自动加载下一页,直到 + * song_count == track_ids_count(歌单尾部)或 == NCM_SONG_MAX(内存上限,footer 显示 x/总数)。 + * 不再硬截断 256(NCM_SONG_MAX 从 256 提升到 1000,约 1.32KB/首 → 歌曲数组 ~1.37MB,14MB 内安全)。 + * 数值权衡见 REPORT §14.8(可再上调,代价是 bss 线性增长:每 +100 首 ≈ +132KB)。 */ +#define NCM_SONG_MAX 1000 /* 歌单曲目/歌曲对象上限(append-only 分页加载) */ +#define NCM_SONG_BATCH 100 /* song_detail 分批每批条数(≤500 安全窗内取保守 100; + * 同时作「下一页」加载粒度;c 数组缓冲 clist[4096] 装 100 首 ≈2KB */ #define NCM_LYRIC_LINES 600 /* 歌词行上限 */ #define NCM_COVER_MAX 288*1024 /* 封面下载限幅 ~288KB */ #define NCM_SEARCH_MAX 32 /* 搜索结果上限(v1 只显示前 32 条,无大缓冲) */ @@ -106,10 +112,15 @@ typedef struct ncm_app_state { int playlist_count; int active_playlist; /* 当前打开歌单的下标;-1=无 */ - /* 当前歌单曲目(id 用 int64_t:网易云歌曲 id 可 >2^31,MIPS32 long 溢出) */ + /* 当前歌单曲目(id 用 int64_t:网易云歌曲 id 可 >2^31,MIPS32 long 溢出)。 + * song_ids[] = detail 返回的全量 trackIds(≤ NCM_SONG_MAX,分页索引); + * songs[] = 已物化的歌曲对象(append-only,与 song_ids[] 前 song_count 首一一对应); + * song_count = 已物化数(列表 count = song_count);track_ids_count = trackIds 总数(≥ song_count, + * 其差值 = 仍有「下一页」待 song_detail 分批拉取)。 */ int64_t song_ids[NCM_SONG_MAX]; ncm_song_t songs[NCM_SONG_MAX]; int song_count; + int track_ids_count; /* detail 取回的 trackIds 总数(≤ NCM_SONG_MAX) */ /* 播放页视图态 */ int playing_song; /* 正在播放歌曲在 songs[] 下标(UI 侧缓存);-1=无 */ @@ -249,8 +260,10 @@ int appstate_ensure_uid(void); /* 若 uid 缺失则 login_status_check 取 int appstate_load_playlists(void); /* 拉我的歌单 */ int appstate_load_playlist_songs(int idx); /* 兼容:整批载入(内部走分页循环) */ int appstate_load_playlist_songs_page(int idx, int offset, int limit); - /* 服务端分页载入第 offset 页(offset=0 重头填, - * offset>0 追加到 g_st.songs[offset..]);写 g_st.song_count */ + /* detail(全量 trackIds)后按页拉 song_detail: + * offset=0 重头(重建 song_ids[]/track_ids_count,清空后载第 0 页); + * offset>0 追加第 offset 页到 g_st.songs[offset..]; + * 写 g_st.song_count(=offset+本页实际数)。 */ void appstate_apply_playlist_cover(int idx); void appstate_set_playing_song(int idx, const char *cover_url); int appstate_current_playing(void); /* 返回当前播放歌曲下标(UI) */ diff --git a/patch_v2/ncm_app/app/ui/playlist.c b/patch_v2/ncm_app/app/ui/playlist.c index d036f33..7323786 100644 --- a/patch_v2/ncm_app/app/ui/playlist.c +++ b/patch_v2/ncm_app/app/ui/playlist.c @@ -34,7 +34,7 @@ static int g_placked; static volatile int g_plloading; static volatile int g_plloaded_idx; static volatile int g_ploffset; /* 本页起始下标(0=首屏重头;>0=触底追加) */ -static volatile int g_pl_more; /* 1=尚有下一页可加载(页满且未到 NCM_SONG_MAX) */ +static volatile int g_pl_more; /* 1=尚有下一页可加载(song_count < track_ids_count 且未到 NCM_SONG_MAX) */ static void pl_drag_reset(void) { @@ -157,7 +157,7 @@ static void pl_draw_empty(void *ctx) if (g_load_err == 0 && g_st.song_count != 0) return; if (g_plloading) { ncm_draw_empty(r_list, "LOADING", "正在加载曲目..."); return; } if (g_load_err == NCM_ERR_AUTH) ncm_draw_empty(r_list, "SESSION EXPIRED", "请重新登录"); - else if (g_load_err) ncm_draw_empty(r_list, "LOAD FAILED", "返回后重试"); + else if (g_load_err) ncm_draw_empty(r_list, "LOAD FAILED", "点按重试"); else ncm_draw_empty(r_list, "NO TRACKS", "这个歌单是空的"); } @@ -197,12 +197,11 @@ static void *playlist_worker(void *arg) int rc; if (g_host_mock) rc = 0; else if (my_idx < 0) rc = NCM_ERR_ARG; - else rc = appstate_load_playlist_songs_page(my_idx, off, NCM_PLAYLIST_PAGE); + else rc = appstate_load_playlist_songs_page(my_idx, off, NCM_SONG_BATCH); pthread_mutex_lock(&g_plmu); g_load_err = rc; - /* 页满(==NCM_PLAYLIST_PAGE)且未到记忆上限 → 还有下一页;否则到屏尾 */ - int loaded = (rc == NCM_OK) ? (g_st.song_count - off) : 0; - g_pl_more = (rc == NCM_OK) && (loaded >= NCM_PLAYLIST_PAGE) && + /* 本页后仍有多余 trackIds(tail 未到)且未到内存上限 → 还有下一页;否则到屏尾。 */ + g_pl_more = (rc == NCM_OK) && (g_st.song_count < g_st.track_ids_count) && (g_st.song_count < NCM_SONG_MAX); g_plloading = 0; g_plloaded_idx = my_idx; @@ -211,10 +210,11 @@ static void *playlist_worker(void *arg) return NULL; } -/* 触底加载下一页:仅当仍有下一页、未在载、且未到 NCM_SONG_MAX 时发。 */ +/* 触底加载下一页:仅当仍有下一页、未在载、未失败、未到 NCM_SONG_MAX 时发。 */ static void pl_maybe_load_more(void) { - if (g_plthr_on || g_load_err != 0 || g_host_mock) return; + if (g_plthr_on || g_host_mock) return; + if (g_load_err != 0) return; /* 失败态:走点按重试,不自动循环 */ if (!g_pl_more) return; if (g_st.song_count >= NCM_SONG_MAX) return; /* 视口底接近列表底(最后一行即将可见即预取) */ @@ -222,11 +222,11 @@ static void pl_maybe_load_more(void) int max_scroll = list_px - r_list.h; if (max_scroll < 0) max_scroll = 0; if (meow_listview_scroll(g_list) < max_scroll - APP_ROW_H * 2) return; - g_ploffset = g_st.song_count; + g_ploffset = g_st.song_count; /* 下一页起始 = 已物化数 */ g_plloading = 1; g_plthr_on = 1; if (pthread_create(&g_plthr, NULL, playlist_worker, NULL) != 0) { - g_plthr_on = 0; g_plloading = 0; g_load_err = NCM_ERR_IO; + g_plthr_on = 0; g_plloading = 0; ncm_snack("加载失败"); } } @@ -236,6 +236,7 @@ void playlist_refresh(void) if (g_plthr_on) return; g_load_err = 0; g_st.song_count = 0; + g_st.track_ids_count = 0; g_ploffset = 0; g_pl_more = 0; g_plloading = 1; @@ -256,8 +257,18 @@ void playlist_tick(uint64_t now) g_plloading = 0; if (g_plloaded_idx != g_st.active_playlist) { playlist_refresh(); return; } if (g_load_err != 0) { - if (g_ploffset == 0) g_st.song_count = 0; /* 首屏失败 → 整页空态 */ - else { ncm_snack("加载失败"); g_load_err = 0; } /* 追加失败 → 保留已载歌曲 */ + if (g_ploffset == 0) { + g_st.song_count = 0; /* 首屏失败 → 整页空态(点按重试) */ + pl_apply(1); + } else { + /* 追加失败 → 保留已载歌曲 + 重置 g_pl_more(触底/点击可重试) */ + ncm_snack("加载失败"); + g_load_err = 0; + g_pl_more = (g_st.song_count < g_st.track_ids_count) && + (g_st.song_count < NCM_SONG_MAX); + pl_apply(0); + } + return; } pl_apply(g_ploffset == 0); /* 首屏回顶;触底追加保持滚动 */ } @@ -289,7 +300,14 @@ int playlist_on_tap(int x, int y) if (x >= r_cmt.x && x < r_cmt.x + r_cmt.w && y >= r_cmt.y && y < r_cmt.y + r_cmt.h) { pl_drag_reset(); ncm_snack("评论功能开发中"); return 1; } - if (g_load_err != 0 || g_st.song_count == 0) return 0; + if (g_load_err != 0) { + /* 失败态(LOAD FAILED / SESSION EXPIRED / NETWORK ERROR):点按内容区重试。 */ + pl_drag_reset(); + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); + playlist_refresh(); + return 1; + } + if (g_st.song_count == 0) return 0; /* 空歌单(无失败):不重试 */ if (meow_widget_hit(g_list, x, y)) { /* 误触修复:小拖动( 上限,读满 NCM_SONG_MAX 截断)。 + * + * 编译(宿主):$CC_HOST -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ + * -Icore -I../vendor/jsmn -c bench/pagination_sim.c -o /tmp/ps.o + * $CC_HOST -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ + * -Icore -I../vendor/jsmn -c app/logic/appstate.c -o /tmp/ps_app.o + * $CC_HOST -O2 -Wl,--gc-sections -o pagination_sim /tmp/ps.o /tmp/ps_app.o -lpthread + * 运行:./pagination_sim (0 网络;PASS/FAIL 退出码) + */ +#include "../app/ncm_app_internal.h" +#include +#include +#include + +/* appstate.c 依赖的全局(app.c 提供;本 bench 只测分页路径,仅 g_st 被用到) */ +ncm_app_state_t g_st; +ncm_play_run_t g_play = { .mu = PTHREAD_MUTEX_INITIALIZER, .state = NCM_PC_STOPPED, .cur_song = -1 }; +volatile int g_app_quit = 0; +volatile int g_net_ready = 0; +int g_host_mock = 0; + +/* ncm_strlcpy:appstate_apply_playlist_cover 用到(从 core ncm_common 借;此处最小内联实现) */ +size_t ncm_strlcpy(char *dst, const char *src, size_t sz) +{ + size_t n = 0; + if (sz) { + while (src[n] && n < sz - 1) { dst[n] = src[n]; n++; } + dst[n] = 0; + } + while (src[n]) n++; + return n; +} + +/* ---- 网络端点 stub(离线,合成数据) ---- */ +static int g_pid_total; /* detail 应返回的 trackIds 总数(供测试注入) */ +static int g_detail_calls; +static int g_song_calls; + +int ncm_api_playlist_detail(const char *device_id, const char *cookie, const char *playlist_id, + char *name, size_t name_sz, char *cover, size_t cover_sz, + int64_t *ids, int id_cap, int *out_id_n) +{ + (void)device_id; (void)cookie; (void)cover_sz; + g_detail_calls++; + if (name) snprintf(name, name_sz, "Sim Playlist"); + if (cover) cover[0] = 0; + int n = (g_pid_total < id_cap) ? g_pid_total : id_cap; + for (int i = 0; i < n; i++) ids[i] = 1000000LL + i; /* 合成连续 id */ + *out_id_n = n; + return NCM_OK; +} + +int ncm_api_song_detail(const char *device_id, const char *cookie, const int64_t *ids, int n, + ncm_song_t *out, int max, int *out_n) +{ + (void)device_id; (void)cookie; + g_song_calls++; + int m = (n < max) ? n : max; + for (int i = 0; i < m; i++) { + snprintf(out[i].id, sizeof out[i].id, "%lld", (long long)ids[i]); + snprintf(out[i].name, sizeof out[i].name, "Song%lld", (long long)ids[i]); + snprintf(out[i].art_name, sizeof out[i].art_name, "Artist%lld", (long long)ids[i]); + snprintf(out[i].al_pic, sizeof out[i].al_pic, "http://p.music.126.net/cov%lld.jpg", (long long)ids[i]); + } + *out_n = m; + return NCM_OK; +} + +/* ---- 驱动:整批载入到尾部/上限,返回最终 song_count ---- */ +static int load_all(int pid_total, int *detail_calls, int *song_calls) +{ + memset(&g_st, 0, sizeof g_st); + g_st.playlist_count = 1; + snprintf(g_st.playlists[0].id, sizeof g_st.playlists[0].id, "pl0"); + g_st.playlists[0].track_count = pid_total; + g_pid_total = pid_total; + g_detail_calls = 0; g_song_calls = 0; + + int rc = appstate_load_playlist_songs_page(0, 0, NCM_SONG_BATCH); + if (rc != NCM_OK) { printf(" page0 rc=%d\n", rc); return -1; } + int off = g_st.song_count; + while (off < g_st.track_ids_count && off < NCM_SONG_MAX) { + rc = appstate_load_playlist_songs_page(0, off, NCM_SONG_BATCH); + if (rc != NCM_OK) { printf(" page(%d) rc=%d\n", off, rc); return -1; } + int nxt = g_st.song_count; + if (nxt <= off) break; + off = nxt; + } + if (detail_calls) *detail_calls = g_detail_calls; + if (song_calls) *song_calls = g_song_calls; + return g_st.song_count; +} + +static int g_fail = 0; +#define CHECK(cond, fmt, ...) do { \ + if (cond) printf(" PASS " fmt "\n", ##__VA_ARGS__); \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); g_fail = 1; } \ +} while (0) + +int main(void) +{ + /* (a) 130 首(< NCM_SONG_MAX):应读全程,track_ids_count=130,song_count=130,尾部停。 */ + printf("== case A: 130 songs (reads to tail) ==\n"); + int dc = 0, sc = 0; + int n = load_all(130, &dc, &sc); + CHECK(n == 130, "song_count=%d (expect 130)", n); + CHECK(g_st.track_ids_count == 130, "track_ids_count=%d (expect 130)", g_st.track_ids_count); + CHECK(dc == 1, "detail calls=%d (expect 1, index fetched once)", dc); + CHECK(sc == 2, "song_detail calls=%d (expect 2 = 100 + 30 pages)", sc); + CHECK(strcmp(g_st.songs[129].name, "Song1000129") == 0, "last song name='%s'", g_st.songs[129].name); + CHECK(g_st.songs[129].al_pic[0] != 0, "last song has al_pic='%s'", g_st.songs[129].al_pic); + /* 越过尾部再 load:应无操作(不越界) */ + int rc = appstate_load_playlist_songs_page(0, 130, NCM_SONG_BATCH); + CHECK(rc == NCM_OK && g_st.song_count == 130, "load past tail: rc=%d song_count=%d", rc, g_st.song_count); + + /* (b) 5191 首(> NCM_SONG_MAX):索引封顶 NCM_SONG_MAX,读满封顶截断。 */ + printf("== case B: 5191 songs (capped at NCM_SONG_MAX) ==\n"); + dc = sc = 0; + n = load_all(5191, &dc, &sc); + CHECK(n == NCM_SONG_MAX, "song_count=%d (expect NCM_SONG_MAX=%d)", n, NCM_SONG_MAX); + CHECK(g_st.track_ids_count == NCM_SONG_MAX, "track_ids_count=%d (index capped)", g_st.track_ids_count); + CHECK(dc == 1, "detail calls=%d", dc); + CHECK(sc == NCM_SONG_MAX / NCM_SONG_BATCH, "song_detail calls=%d (expect %d full pages)", sc, NCM_SONG_MAX / NCM_SONG_BATCH); + /* 越界再 load:应 NCM_OK 但不动 song_count */ + rc = appstate_load_playlist_songs_page(0, NCM_SONG_MAX, NCM_SONG_BATCH); + CHECK(rc == NCM_OK && g_st.song_count == NCM_SONG_MAX, "load past cap: rc=%d song_count=%d", rc, g_st.song_count); + + /* (c) 空歌单(0 首) */ + printf("== case C: empty playlist ==\n"); + n = load_all(0, &dc, &sc); + CHECK(n == 0 && g_st.track_ids_count == 0, "empty: song_count=%d track_ids_count=%d", n, g_st.track_ids_count); + + printf("%s\n", g_fail ? "RESULT: FAIL" : "RESULT: PASS"); + return g_fail ? 1 : 0; +} diff --git a/patch_v2/ncm_app/build_ncm_host.sh b/patch_v2/ncm_app/build_ncm_host.sh index d354e8a..f3c63ba 100755 --- a/patch_v2/ncm_app/build_ncm_host.sh +++ b/patch_v2/ncm_app/build_ncm_host.sh @@ -19,7 +19,7 @@ INC="-I. -I../sdk -I../meow_launcherd -Icore \ echo "==> host SDK objects" SDK_SRC="meow_ui_gfx meow_ui_sys meow_ui_theme meow_ui_vol meow_ui_log meow_ui_widget \ meow_ui_input meow_ui_activity meow_ui_dialog meow_ui_ime meow_ui_draw meow_ui_view \ - meow_ui_img meow_ui_config meow_ui_nav meow_ui_media meow_notify_ipc" + meow_ui_img meow_ui_config meow_ui_nav meow_ui_media meow_notify_ipc meow_pinyin" for f in $SDK_SRC; do $CC_HOST -O2 -c -DMEOW_UI_HOST_TEST -I"$SDK_DIR" -I"$ROOT/patch_v2/meow_launcherd" \ "$SDK_DIR/$f.c" -o "$OUT/host/sdk_$f.o" || { echo "sdk $f FAILED"; exit 1; } diff --git a/patch_v2/ncm_app/ncm b/patch_v2/ncm_app/ncm index 3bc9af0..d9c6ff9 100755 Binary files a/patch_v2/ncm_app/ncm and b/patch_v2/ncm_app/ncm differ diff --git a/patch_v2/ncm_app/ncm_host b/patch_v2/ncm_app/ncm_host index 14ec7af..b7f898f 100755 Binary files a/patch_v2/ncm_app/ncm_host and b/patch_v2/ncm_app/ncm_host differ