diff --git a/patch_v2/ncm_app/REPORT.md b/patch_v2/ncm_app/REPORT.md index 0185510..1a208e4 100644 --- a/patch_v2/ncm_app/REPORT.md +++ b/patch_v2/ncm_app/REPORT.md @@ -1293,3 +1293,164 @@ db5b78ad9e8f40e6306484efe2a08eb3 ncm # mipsel 静态 App - 设备日志中 ipc_media META 每秒一对(state=0 空态也发)为既有行为,本批未动。 - **未碰** /data/recovery_all、未 killall hiby_player、未主动重启设备(§16.3 的多次重启均为 被测路径触发的设备自重启)。 + +--- + +## 17. 三期第三批:歌曲对象瘦身 + 滑动窗口(任意长度歌单,取消 1000 截断)+ 播放快照(2026-09-01) + +> 范围:仅 `patch_v2/ncm_app/`(含 `core/` 结构体瘦身)。未动 `sdk/`、`meow_launcherd/`、 +> `meow_audiod/`、`vendor/`。风控纪律:本批真实 NCM 请求 **0 次**(全部离线 stub + /tmp 盘缓存)。 +> **本批未部署真机**(设备音频线挂死排查中,ncm 二进制不动卡;部署留主会话)。 + +### 17.1 动机 + +§14 的歌单分页(detail 全量 trackIds 驱动 song_detail 分批 100、触底加载)仍受 +`NCM_SONG_MAX=1000` 内存硬上限约束(每首 `ncm_song_t` ≈1324B → 1000 首 ≈1.32MB bss), +用户「我喜欢」6000+ 首只能看到前 1000。本批把「全量物化」改为**滑动窗口**:内存只保留 +视口附近的歌曲对象(256 首),滚动出窗释放、入窗按需拉取,支撑任意长度歌单。 + +### 17.2 歌曲对象瘦身(core/ncm_api.h,1324B → 608B,-54%) + +| 字段 | 旧 | 新 | 依据 | +|---|---|---|---| +| `id` | 32 | 24 | 数字文本,int64 最大 19 位 + NUL | +| `name` | 256 | 128 | 列表/播放页展示均按宽再截断(`ncm_text_fit`/listview) | +| `art_name` | 256 | 96 | 首个歌手名 | +| `album_name` | 256 | 96 | 同上 | +| `al_pic` | 512 | 256 | picUrl 实际 <200 字符;`obj_str` 截断安全 | +| `duration_ms` | `long` | `int32_t` | <2^31ms≈24 天;MIPS32 的 long 本就 32 位(零行为变化) | + +解析侧(`parse_songs_array`)全部走 `sizeof 字段` 拷贝,无需改动;core 测试 +(`test_ncm_core.c` 断言 id/name/ar/al/dt/fee/al_pic)宿主 + qemu-mipsel 全 PASS。 + +### 17.3 滑动窗口(appstate.c + playlist.c) + +- **内存模型**(`ncm_app_internal.h`): + - `song_ids[NCM_TRACKID_MAX=16384]` = detail 取回的全量 trackIds 索引(int64,128KB bss, + 不再 1000 截断;16384×~13B ≈ 213KB JSON,远在 2MB 响应限幅内); + - `songs[NCM_SONG_WINDOW=256]` + `song_valid[256]` 位图 = 物化窗口(256×608B≈152KB bss), + `songs[i] ↔ 全局下标 window_base+i`; + - 列表 count = `track_ids_count`(全量),未物化行画占位「··· / 加载中」。 +- **API**(`app/logic/appstate.c`,全锁 `g_song_mu`;网络/盘 IO 在锁外,锁内仅内存搬移): + - `appstate_song_ensure_range(lo,hi)`:窗口未覆盖先滑窗(重叠区 `songs+valid` 一起 + memmove 保留,出窗槽位失效),缺失槽先读盘缓存,miss 攒批 song_detail 补载并落盘; + - `appstate_song_copy(gidx,&out)` / `appstate_range_loaded(lo,hi)`:锁内读窗(UI 行回调); + - `appstate_song_fetch(gidx,&out)`:任意下标物化(窗→盘→song_detail 单曲),**不滑窗** + (播放线程专用,避免与浏览视口抢窗口造成乒乓); + - `appstate_adopt_songs(n)`:收编外部已填的 `songs[0..n)`(搜索结果)为窗口 base=0 播放上下文; + - 兼容入口 `appstate_load_playlist_songs_page(idx,offset,limit)`:offset=0 重建索引+物化 + 首段;offset>0 = ensure [offset,offset+limit)。 +- **批网格对齐预取**:补载区间对齐到 `NCM_SONG_BATCH=100` 的全局网格([a0,b0) ≤100), + 逐行滚动不产生小请求放大——6000 首全程下滚 song_detail 调用恰 = 60 次(6000/100)。 +- **盘缓存(app/logic/songmeta.c,新)**:拉过的 song_detail 结果逐首序列化到 + `/meta/.bin`(头 magic/ver/size + 结构体镜像;ver/size 失配判 miss)。 + 刻意不进 ncm_cache LRU 池:meta 每首 ≈600B(一万首 ≈6MB),不被清音频缓存误清。 + 效果:二次进入歌单 / 回滚全程 **0 网络请求**(离线可浏览已拉过的部分)。 +- **UI(playlist.c)**:`pl_maybe_load_more`(触底追加)退役 → `pl_maybe_ensure`(视口 ±24 行 + 有未物化槽 → 后台 worker ensure);footer 改「视口区间 / 总数」(如 `5965-5988 / 6000 首`); + 首屏失败整页空态 + 点按重试不变;补载失败 snack + 占位行保留(滚动/点按再试)。 + +### 17.4 播放兼容(窗口滑出当前播放曲不许崩) + +- **播放快照**:`g_play.play_song`(mu 保护)——`run_song` 起播时物化目标曲 + (`song_copy` 窗内命中 / `song_fetch` 窗外)并整体拷入快照;之后下载(id)、时长、 + 歌词 worker(id)、IPC META(title/artist/album)、播放页 UI(歌名/歌手/封面)**全读快照**, + 与窗口完全解耦。窗口滑出当前播放曲 → 播放/歌词/媒体会话照常。 +- 上下首/自然顺延边界:`g_st.song_count`(窗口内有效数)→ `g_st.track_ids_count`(全量索引), + cur_song 语义 = 全局下标不变。 +- **占位行可直接点歌**:`player_play_song(row)` 不要求行已物化——封面回落歌单封面, + 元数据由播放线程 fetch 后快照接管;UI tick 按快照 id 变化纠正封面/歌名(快照滞后 settle)。 +- 搜索上下文:`search_worker` 结果经 `adopt_songs` 收编(song_ids 同步 strtoll), + 播放/上下一首对结果集生效(同旧语义)。 + +### 17.5 内存对比(6000 首歌单场景) + +| 项 | 改前(§14) | 改后(§17) | +|---|---|---| +| `ncm_song_t` | 1324B | 608B(-54%) | +| 歌曲对象数组 | 1000×1324B ≈ 1292KB(**且 1000 截断,6000 首只能看前 1000**) | 256×608B ≈ 152KB(任意长度恒定) | +| trackIds 索引 | 1000×8B = 8KB(截断) | 16384×8B = 128KB(全量) | +| 有效位图 | — | 256B | +| **合计(歌曲数据)** | **≈1300KB(有截断)** | **≈280KB(无截断)** | +| ncm bss 总量(`size`) | 2052068 | **1080900**(-972KB,-47%) | + +6000 首全量物化的假想成本 6000×1324B≈7.9MB(14MB 预算下不可行)→ 滑窗后与歌单长度无关。 + +### 17.6 验证记录(0 真实 NCM 请求) + +| 项 | 方式 | 结果 | +|---|---|---| +| 窗口基本语义 | `bench/pagination_sim.c`(已更新到新语义,离线 stub 驱动真实 appstate) | **全 PASS**:130 首整装入窗(1 detail + 2 song_detail);5191 首无截断(索引全量 5191、52 批网格、末首可达、出窗槽 copy 失败、越界无操作);空歌单 0 | +| 6000 首滚动全程 | `bench/window_sim.c`(新;真实 appstate + 真实 songmeta 盘缓存 /tmp) | **全 PASS**:首屏 1+1 请求;下滚 750 步(步进 8 行)每步视口 12 行全物化、song_detail 恰 60 次(无放大)、有效槽恒 ≤256;**回滚全程 +0 请求**(盘缓存全命中,6000 meta 文件落盘);窗口滑出播放曲后 `song_fetch` 不滑窗不崩、盘命中免请求、尾部/越界钳制正确 | +| core 回归 | `build_core.sh` + `test_ncm_core_host` + `qemu-mipsel out/test_ncm_core_mipsel` | 宿主 + qemu 全 PASS(结构体瘦身不影响解析断言) | +| mipsel 构建 | `bash build_ncm.sh`(自有代码 `-Wall -Wextra`) | **0 warning**;`text 865604 / data 1480 / bss 1080900` | +| 宿主离屏渲染 | `build_ncm_host.sh` + `MEOW_UI_DUMP`(mock,0 网络) | home/playlist/player/lands/settings/search/login 渲染正常:歌单 6 行 + footer `1-6 6`(视口区间格式)、黑胶 + 快照歌名/歌手 + 歌词高亮;自有代码 0 warning(仅 SDK `meow_ui_vol.c` 既有告警) | + +### 17.7 md5(本批改动文件 + 产物) + +``` +b32e8a80c12de4dac2e4ad52d26ae55c ncm # mipsel 静态 App(新;未部署) +783d3ddf9bfc1905f732f688c54c5447 ncm_host # 宿主离屏调试二进制 +321039c8743af6ccd934c63a3a161e84 app/ncm_app_internal.h +bde871b33298cf25187054650560754a core/ncm_api.h +3c4122e72cc701aa5d0d95862b0201d1 app/logic/appstate.c +dd8fbb70ba5e0978fa75501135c473a3 app/logic/songmeta.c +cb44e2385108ba6cdad242aecf635b64 app/ui/playlist.c +533dafebce47bd60355a1efc7719db9c app/ui/player.c +a5447ac5e6f2ba83405a7a3c19c88dc9 app/play/player_manager.c +74eded2b9a220aba9a50832ff9c6914c app/play/ipc_media.c +d2c2505eede96ddb1387209681894b83 app/ui/search.c +564630846354d3e202de303435e82c59 app/test/test_ncm_app.c +00a1445f83e98a068c4219593bb7d6d8 bench/pagination_sim.c +b2b6869990c8ef3906c100f18c7194e8 bench/window_sim.c +3159c53adf59967cacdad50a58946858 build_ncm.sh +430211ee6895c26c9871da22e9aede76 build_ncm_host.sh +``` + +### 17.8 出入清单(只改 `patch_v2/ncm_app/`) + +``` +修改: + core/ncm_api.h # ncm_song_t 瘦身 1324B→608B(id24/name128/art96/al96/pic256/dt int32) + app/ncm_app_internal.h # 删 NCM_SONG_MAX;+NCM_TRACKID_MAX 16384 / NCM_SONG_WINDOW 256 / + # song_valid[]/window_base;g_play.play_song 快照;窗口 API + songmeta 声明 + app/logic/appstate.c # 滑动窗口(g_song_mu/滑窗 memmove/批网格预取/盘缓存补载)+ + # song_copy/range_loaded/song_fetch/adopt_songs + 兼容入口改窗口语义 + + # autoplay 走 song_copy + logout 清窗口 + strncpy→ncm_strlcpy 消告警 + app/ui/playlist.c # 触底分页退役 → 视口 ensure(pl_maybe_ensure)+ 占位行 + footer 视口区间 + app/ui/player.c # 元数据读播放快照(cur_song_meta)+ 快照 id 变化纠正封面/歌名 + + # 占位行可点(播放线程物化) + app/play/player_manager.c # run_song 起播快照 + song_local_file 按 id + 边界 track_ids_count + + # 歌词 worker 读快照 + player_manager_current_song 访问器 + app/play/ipc_media.c # META title/artist/album 读播放快照 + app/ui/search.c # 结果经 appstate_adopt_songs 收编为窗口播放上下文 + app/test/test_ncm_app.c # mock 补窗口字段(song_valid/track_ids_count)+ 播放快照 + bench/pagination_sim.c # 更新到窗口语义(5191 无截断/出窗 copy 失败/网格批数) + build_ncm.sh / build_ncm_host.sh # +app/logic/songmeta.c + ncm / ncm_host # 重编产物 +新增: + app/logic/songmeta.c # 歌曲元数据盘缓存(/meta/.bin,magic/ver/size 头) + bench/window_sim.c # 6000 首滚动全程离线验证(0 请求,盘缓存回滚 0 新增请求) + REPORT.md # 本批记录(§17) +(未动 sdk/、meow_launcherd/、meow_audiod/、vendor/;未部署真机;未动设备系统/恢复区) +``` + +### 17.9 诚实出入 / 已知限制 + +- **真机未部署/未验**(风控 + 设备音频线排查中;部署留主会话)。真机验收点:① 6000+ 歌单 + 全程下滚/回滚无占位残影、footer 视口区间随滚动更新;② 占位行直点能播(fetch 物化); + ③ 播放中猛滑列表把当前曲滑出窗 → 播放/歌词/媒体卡片不断;④ 二次进入歌单离线可浏览 + 已拉过的部分(meta 盘缓存);⑤ 歌单 >16384 首时 trackIds 索引截断(footer 总数仍对, + 尾部不可达——16384 是 128KB bss 的取舍,当前用户最大歌单 6000+ 有足够余量)。 +- **单写者假设**:ensure/page 仅由 playlist worker 驱动(`g_plthr_on` 互斥),播放线程 + `song_fetch` 不写窗口;搜索 worker 的 cloudsearch 直接写 `songs[]` 前半再 `adopt_songs` + 收编(与旧代码同款的单写者时序,未加新锁)。 +- **快照滞后一瞬**:切歌瞬间(next/prev/占位行直点)播放页歌名/封面仍是旧曲快照, + play 线程物化后(通常 <100ms,窗外需一次盘读/网络)由 tick 按快照 id 纠正——一帧级旧 + 内容,与 §14.7 歌词锁残余同级接受。 +- **切歌单时播放上下文**:打开新歌单会重建索引/清窗口(同 §14 既有行为),正在播的旧歌单 + 曲目靠快照续播(比旧模型更安全:旧模型 memset 后 id/dur 全丢);但 cur_song 下标语义 + 随新歌单索引变化(既有竞态,未扩大)。 +- **meta 盘缓存无版本化清理**:结构体再改版靠 ver/size 判 miss 自动重建;无 TTL + (歌曲改名/换封面不主动失效——与 ncm_cache 同级的「缓存可再生」取舍)。 +- **host 构建有 SDK `meow_ui_vol.c`「system 返回值忽略」告警**(SDK 源,非本 App;mipsel musl 无)。 diff --git a/patch_v2/ncm_app/app/logic/appstate.c b/patch_v2/ncm_app/app/logic/appstate.c index 8f4d7c8..2dae430 100644 --- a/patch_v2/ncm_app/app/logic/appstate.c +++ b/patch_v2/ncm_app/app/logic/appstate.c @@ -7,6 +7,9 @@ #define NCM_DEFAULT_DATA "/usr/data/mnt/sd_0/MeowData/dev.player.ncm/data" +/* 滑动窗口互斥(REPORT §17):窗口读写(滑窗/物化/拷贝)全走此锁,见下方窗口节注释。 */ +static pthread_mutex_t g_song_mu = PTHREAD_MUTEX_INITIALIZER; + int appstate_init(void) { const char *dd = getenv("MEOW_DATA_DIR"); @@ -16,7 +19,7 @@ int appstate_init(void) ncm_ctx_init(&g_st.ctx, g_st.data_dir); /* device_id + cookie 载入 + cache */ if (g_st.ctx.cache.dir[0]) - strncpy(g_st.cache_dir, g_st.ctx.cache.dir, sizeof g_st.cache_dir - 1); + ncm_strlcpy(g_st.cache_dir, g_st.ctx.cache.dir, sizeof g_st.cache_dir); else ncm_ctx_default_cache_dir(g_st.data_dir, g_st.cache_dir, sizeof g_st.cache_dir); @@ -80,7 +83,12 @@ void appstate_logout(void) g_st.nickname[0] = 0; g_st.active_playlist = -1; g_st.playing_song = -1; + pthread_mutex_lock(&g_song_mu); + memset(g_st.song_valid, 0, sizeof g_st.song_valid); + g_st.window_base = 0; g_st.song_count = 0; + g_st.track_ids_count = 0; + pthread_mutex_unlock(&g_song_mu); meow_config_remove("ncm.uid"); meow_config_remove("ncm.nickname"); cover_cache_reset(); @@ -120,6 +128,219 @@ int appstate_load_playlists(void) return rc; } +/* ============================================================ + * 滑动窗口(REPORT §17,三期第三批) + * songs[i] ↔ 全局下标 window_base+i;song_valid[] 为槽位有效位图。 + * 写侧(滑窗/物化)与读侧(song_copy)全走 g_song_mu;网络/盘 IO 在锁外做, + * 锁内只做内存搬移,UI 行回调(song_copy)不被网络阻塞。 + * 单写者假设:ensure/page 仅由 playlist worker(及兼容入口)驱动,同一时刻最多一个。 + * ============================================================ */ + +static int slot_valid_locked(int gidx) +{ + int s = gidx - g_st.window_base; + return s >= 0 && s < NCM_SONG_WINDOW && g_st.song_valid[s]; +} + +static void win_recount_locked(void) +{ + int n = 0; + for (int i = 0; i < NCM_SONG_WINDOW; i++) + if (g_st.song_valid[i]) n++; + g_st.song_count = n; +} + +/* 锁内滑窗:重叠区 songs+valid 一起 memmove 保留,非重叠端清零(出窗槽位失效)。 + * 出窗歌曲的元数据此前已落盘(songmeta),丢弃无数据损失。 */ +static void win_slide_locked(int new_base) +{ + int old_base = g_st.window_base; + if (new_base == old_base) return; + int ov_lo = (old_base > new_base) ? old_base : new_base; + int ov_hi = (old_base + NCM_SONG_WINDOW < new_base + NCM_SONG_WINDOW) + ? old_base + NCM_SONG_WINDOW : new_base + NCM_SONG_WINDOW; + if (ov_lo < ov_hi) { + int src = ov_lo - old_base, dst = ov_lo - new_base, n = ov_hi - ov_lo; + memmove(&g_st.songs[dst], &g_st.songs[src], (size_t)n * sizeof(ncm_song_t)); + memmove(&g_st.song_valid[dst], &g_st.song_valid[src], (size_t)n); + if (dst > 0) memset(g_st.song_valid, 0, (size_t)dst); + if (dst + n < NCM_SONG_WINDOW) + memset(&g_st.song_valid[dst + n], 0, (size_t)(NCM_SONG_WINDOW - dst - n)); + } else { + memset(g_st.song_valid, 0, sizeof g_st.song_valid); + } + g_st.window_base = new_base; + win_recount_locked(); +} + +int appstate_range_loaded(int lo, int hi) +{ + if (lo < 0) lo = 0; + if (hi > g_st.track_ids_count) hi = g_st.track_ids_count; + if (lo >= hi) return 1; + pthread_mutex_lock(&g_song_mu); + int ok = 1; + for (int i = lo; i < hi; i++) + if (!slot_valid_locked(i)) { ok = 0; break; } + pthread_mutex_unlock(&g_song_mu); + return ok; +} + +int appstate_song_copy(int gidx, ncm_song_t *out) +{ + if (!out) return -1; + pthread_mutex_lock(&g_song_mu); + int s = gidx - g_st.window_base; + int ok = (s >= 0 && s < NCM_SONG_WINDOW && g_st.song_valid[s]); + if (ok) *out = g_st.songs[s]; + pthread_mutex_unlock(&g_song_mu); + return ok ? 0 : -1; +} + +int appstate_song_ensure_range(int lo, int hi) +{ + if (lo < 0) lo = 0; + if (hi > g_st.track_ids_count) hi = g_st.track_ids_count; + if (lo >= hi) return NCM_OK; + + /* 目标基址:请求区间居中(前后留余量,滚动反向时不立即滑窗),钳制到合法范围。 */ + int base; + if (hi - lo <= NCM_SONG_WINDOW) { + base = (lo + hi) / 2 - NCM_SONG_WINDOW / 2; + if (base > lo) base = lo; + if (base + NCM_SONG_WINDOW < hi) base = hi - NCM_SONG_WINDOW; + int maxb = g_st.track_ids_count - NCM_SONG_WINDOW; + if (maxb < 0) maxb = 0; + if (base > maxb) base = maxb; + if (base < 0) base = 0; + } else { + base = lo; /* 请求区间超窗(不应发生):覆盖前缀 */ + } + + pthread_mutex_lock(&g_song_mu); + int covered = 1; + for (int i = lo; i < hi; i++) + if (!slot_valid_locked(i)) { covered = 0; break; } + if (!covered && base != g_st.window_base) win_slide_locked(base); + else base = g_st.window_base; /* 已覆盖:维持原基址 */ + pthread_mutex_unlock(&g_song_mu); + if (covered) return NCM_OK; + + /* 逐段补齐 [lo,hi) 内 invalid 槽:先试盘缓存(songmeta),miss 攒批 ≤NCM_SONG_BATCH + * 走 song_detail;成功写窗 + 落盘。网络/盘 IO 均在锁外,锁内仅写槽。 */ + static ncm_song_t s_fetch[NCM_SONG_BATCH]; /* 单写者(playlist worker),免 60KB 栈 */ + int rc = NCM_OK; + for (int a = lo; a < hi && rc == NCM_OK; ) { + pthread_mutex_lock(&g_song_mu); + int va = slot_valid_locked(a); + pthread_mutex_unlock(&g_song_mu); + if (va) { a++; continue; } + int b = a + 1; + while (b < hi && b - a < NCM_SONG_BATCH) { + pthread_mutex_lock(&g_song_mu); + int vb = slot_valid_locked(b); + pthread_mutex_unlock(&g_song_mu); + if (vb) break; + b++; + } + /* 批网格对齐预取:把 [a,b) 扩到 song_detail 批边界(§17.4——逐行滚动时若按原始 + * 小区间拉取,song_detail 调用次数会放大到 ~歌单长度/滚动步长;对齐到 100 首 + * 网格后每次补齐整批,总请求数 ≈ 歌单长度/NCM_SONG_BATCH)。 */ + int a0 = (a / NCM_SONG_BATCH) * NCM_SONG_BATCH; + int b0 = ((b + NCM_SONG_BATCH - 1) / NCM_SONG_BATCH) * NCM_SONG_BATCH; + int wend2 = g_st.window_base + NCM_SONG_WINDOW; + if (wend2 > g_st.track_ids_count) wend2 = g_st.track_ids_count; + if (a0 < g_st.window_base) a0 = g_st.window_base; + if (b0 > wend2) b0 = wend2; + if (b0 - a0 > NCM_SONG_BATCH) b0 = a0 + NCM_SONG_BATCH; + /* 本段 [a0,b0):盘命中直接写窗,有效槽跳过,miss 记录待批拉 */ + int64_t ids[NCM_SONG_BATCH]; + int gslot[NCM_SONG_BATCH]; + int m = 0; + for (int i = a0; i < b0; i++) { + pthread_mutex_lock(&g_song_mu); + int vv = slot_valid_locked(i); + pthread_mutex_unlock(&g_song_mu); + if (vv) continue; + char sid[24]; + snprintf(sid, sizeof sid, "%lld", (long long)g_st.song_ids[i]); + ncm_song_t ms; + if (songmeta_load(g_st.cache_dir, sid, &ms) == NCM_OK) { + pthread_mutex_lock(&g_song_mu); + int s = i - g_st.window_base; + if (s >= 0 && s < NCM_SONG_WINDOW) { + g_st.songs[s] = ms; + g_st.song_valid[s] = 1; + } + pthread_mutex_unlock(&g_song_mu); + } else { + ids[m] = g_st.song_ids[i]; + gslot[m] = i; + m++; + } + } + if (m > 0) { + int bn = 0; + int r2 = ncm_api_song_detail(g_st.ctx.device_id, g_st.ctx.cookie, + ids, m, s_fetch, NCM_SONG_BATCH, &bn); + if (r2 != NCM_OK) { rc = r2; break; } + if (bn > m) bn = m; + for (int k = 0; k < bn; k++) { /* song_detail 结果与请求 ids 同序 */ + pthread_mutex_lock(&g_song_mu); + int s = gslot[k] - g_st.window_base; + if (s >= 0 && s < NCM_SONG_WINDOW) { + g_st.songs[s] = s_fetch[k]; + g_st.song_valid[s] = 1; + } + pthread_mutex_unlock(&g_song_mu); + char sid[24]; + snprintf(sid, sizeof sid, "%lld", (long long)ids[k]); + songmeta_save(g_st.cache_dir, sid, &s_fetch[k]); + } + if (bn < m) rc = NCM_ERR_JSON; /* 部分返回:缺槽留 invalid,下次 ensure 再试 */ + } + a = b; + } + pthread_mutex_lock(&g_song_mu); + win_recount_locked(); + pthread_mutex_unlock(&g_song_mu); + return rc; +} + +int appstate_song_fetch(int gidx, ncm_song_t *out) +{ + if (!out) return NCM_ERR_ARG; + if (appstate_song_copy(gidx, out) == 0) return NCM_OK; /* 窗内直接命中 */ + if (gidx < 0 || gidx >= g_st.track_ids_count) return NCM_ERR_ARG; + int64_t id = g_st.song_ids[gidx]; + if (id == 0) return NCM_ERR_ARG; /* 无索引(如搜索上下文窗外) */ + char sid[24]; + snprintf(sid, sizeof sid, "%lld", (long long)id); + if (songmeta_load(g_st.cache_dir, sid, out) == NCM_OK) return NCM_OK; /* 盘缓存 */ + int bn = 0; + int rc = ncm_api_song_detail(g_st.ctx.device_id, g_st.ctx.cookie, &id, 1, out, 1, &bn); + if (rc != NCM_OK) return rc; + if (bn < 1) return NCM_ERR_NOTFOUND; + songmeta_save(g_st.cache_dir, sid, out); + return NCM_OK; +} + +void appstate_adopt_songs(int n) +{ + pthread_mutex_lock(&g_song_mu); + if (n < 0) n = 0; + if (n > NCM_SONG_WINDOW) n = NCM_SONG_WINDOW; + memset(g_st.song_valid, 0, sizeof g_st.song_valid); + for (int i = 0; i < n; i++) { + g_st.song_valid[i] = 1; + g_st.song_ids[i] = strtoll(g_st.songs[i].id, NULL, 10); + } + g_st.window_base = 0; + g_st.song_count = n; + g_st.track_ids_count = n; + pthread_mutex_unlock(&g_song_mu); +} + int appstate_load_playlist_songs_page(int idx, int offset, int limit) { if (idx < 0 || idx >= g_st.playlist_count) return NCM_ERR_ARG; @@ -130,61 +351,44 @@ int appstate_load_playlist_songs_page(int idx, int offset, int limit) if (offset < 0) offset = 0; /* 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)。 */ + * §14 的分页(append-only 到 NCM_SONG_MAX=1000 截断)在 §17 升级为滑动窗口: + * detail(n=1)仍取【全量】trackIds 入 song_ids[](≤ NCM_TRACKID_MAX,支撑 6000+ 首), + * 歌曲对象只物化视口附近的 NCM_SONG_WINDOW 首(offset 为「要确保的区间」起点)。 */ if (offset == 0) { - /* 重头:重建 trackIds 索引 + 清空已物化歌曲(song_count 无论成败都归零)。 */ + /* 重头:清窗口 + 重建 trackIds 索引(song_count/track_ids_count 无论成败都归零)。 */ + pthread_mutex_lock(&g_song_mu); + memset(g_st.song_valid, 0, sizeof g_st.song_valid); memset(g_st.song_ids, 0, sizeof g_st.song_ids); - memset(g_st.songs, 0, sizeof g_st.songs); + g_st.window_base = 0; g_st.song_count = 0; g_st.track_ids_count = 0; + pthread_mutex_unlock(&g_song_mu); 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); + g_st.song_ids, NCM_TRACKID_MAX, &idn); if (rc != NCM_OK) return rc; g_st.track_ids_count = idn; - /* 首屏只需第 0 页:song_detail 只拉前 limit 首,其余待滚动触底再拉(分页驱动)。 */ + appstate_apply_playlist_cover(idx); } else if (g_st.track_ids_count == 0) { /* 防御:offset>0 但没有索引(不应发生;主路径先走 offset=0)。 */ return NCM_ERR_ARG; } - 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; + if (offset >= g_st.track_ids_count) return NCM_OK; /* 已到尾部 */ + int hi = offset + limit; + if (hi > g_st.track_ids_count) hi = g_st.track_ids_count; + return appstate_song_ensure_range(offset, hi); } int appstate_load_playlist_songs(int idx) { - /* 兼容入口:整批载入(内部按页循环到 NCM_SONG_MAX 或歌单尾部)。 */ + /* 兼容入口:载索引 + 物化首个窗口。 */ 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 nxt = g_st.song_count; - if (nxt <= off) break; /* 无进展:防死循环 */ - off = nxt; - } - return NCM_OK; + int hi = (g_st.track_ids_count < NCM_SONG_WINDOW) ? g_st.track_ids_count : NCM_SONG_WINDOW; + return appstate_song_ensure_range(0, hi); } void appstate_apply_playlist_cover(int idx) @@ -203,7 +407,7 @@ void appstate_set_playing_song(int idx, const char *cover_url) int appstate_current_playing(void) { - if (g_st.playing_song < 0 || g_st.playing_song >= g_st.song_count) return -1; + if (g_st.playing_song < 0 || g_st.playing_song >= g_st.track_ids_count) return -1; return g_st.playing_song; } @@ -227,18 +431,20 @@ static int autoplay_try_playlist(int pl) return 0; } char cpath[600]; - for (int i = 0; i < g_st.song_count; i++) { - if (ncm_cache_lookup(&g_st.ctx.cache, g_st.songs[i].id, ".mp3", + for (int i = 0; i < g_st.track_ids_count; i++) { /* 滑窗模型:锁内拷贝窗内有效槽 */ + ncm_song_t s; + if (appstate_song_copy(i, &s) != 0) break; /* 出窗即停(首页之后未物化) */ + if (ncm_cache_lookup(&g_st.ctx.cache, s.id, ".mp3", cpath, sizeof cpath) == NCM_OK) { g_st.active_playlist = pl; meow_log(MEOW_LOG_INFO, "autoplay: playlist[%d] song[%d] id=%s cached -> play", - pl, i, g_st.songs[i].id); + pl, i, s.id); player_manager_play(i); return 1; } } - meow_log(MEOW_LOG_INFO, "autoplay: playlist[%d] first page %d songs, no cached track", - pl, g_st.song_count); + meow_log(MEOW_LOG_INFO, "autoplay: playlist[%d] first window %d/%d songs, no cached track", + pl, g_st.song_count, g_st.track_ids_count); return 0; } diff --git a/patch_v2/ncm_app/app/logic/songmeta.c b/patch_v2/ncm_app/app/logic/songmeta.c new file mode 100644 index 0000000..a453514 --- /dev/null +++ b/patch_v2/ncm_app/app/logic/songmeta.c @@ -0,0 +1,68 @@ +/* logic/songmeta.c — 歌曲元数据本地缓存(REPORT §17 滑动窗口配套)。 + * + * 拉取成功的 song_detail 结果逐首序列化到 /meta/.bin;滑动窗口补载时 + * 先读盘(命中免 song_detail 请求),离线/二次进入歌单滚动全程零网络。 + * + * 取舍(刻意从简): + * - 不进 ncm_cache LRU 池:meta 每首 ≈600B,一万首 ≈6MB,不参与 10GB 级 LRU 淘汰, + * 也不被 ncm_cache_clear(只删 song_* 顶层文件)误清——清音频缓存不丢浏览元数据。 + * - 格式 = 头(magic/ver/size) + ncm_song_t 原始内存镜像:同机同构读写,跨架构/结构体 + * 改版由 ver+size 失配判 miss(缓存可再生,最坏结果 = 回退 song_detail 网络拉取)。 + */ +#include "../ncm_app_internal.h" +#include +#include +#include +#include + +#define SONGMETA_MAGIC 0x4D4D434Eu /* "NCMM" */ +#define SONGMETA_VER 1 + +typedef struct { + uint32_t magic; + uint16_t ver; + uint16_t size; /* sizeof(ncm_song_t),失配判 miss */ +} songmeta_hdr_t; + +static int songmeta_path(const char *dir, const char *id, char *out, size_t sz) +{ + if (!dir || !dir[0] || !id || !id[0]) return -1; + int n = snprintf(out, sz, "%s/meta/%s.bin", dir, id); + return (n > 0 && (size_t)n < sz) ? 0 : -1; +} + +int songmeta_load(const char *cache_dir, const char *song_id, ncm_song_t *out) +{ + char path[NCM_CACHE_DIR_MAX + 48]; + if (songmeta_path(cache_dir, song_id, path, sizeof path) != 0) return NCM_ERR_NOENT; + FILE *f = fopen(path, "rb"); + if (!f) return NCM_ERR_NOENT; + songmeta_hdr_t h; + int ok = (fread(&h, 1, sizeof h, f) == sizeof h) && + h.magic == SONGMETA_MAGIC && h.ver == SONGMETA_VER && + h.size == sizeof(ncm_song_t) && + (fread(out, 1, sizeof(ncm_song_t), f) == sizeof(ncm_song_t)); + fclose(f); + return ok ? NCM_OK : NCM_ERR_NOENT; +} + +int songmeta_save(const char *cache_dir, const char *song_id, const ncm_song_t *s) +{ + if (!cache_dir || !cache_dir[0] || !song_id || !song_id[0] || !s) return NCM_ERR_ARG; + char dir[NCM_CACHE_DIR_MAX + 16]; + int n = snprintf(dir, sizeof dir, "%s/meta", cache_dir); + if (n <= 0 || (size_t)n >= sizeof dir) return NCM_ERR_ARG; + mkdir(cache_dir, 0755); /* 幂等(缓存目录通常已由 ncm_cache_init 建好) */ + mkdir(dir, 0755); + + char path[NCM_CACHE_DIR_MAX + 48]; + if (songmeta_path(cache_dir, song_id, path, sizeof path) != 0) return NCM_ERR_ARG; + FILE *f = fopen(path, "wb"); + if (!f) return NCM_ERR_IO; + songmeta_hdr_t h = { SONGMETA_MAGIC, SONGMETA_VER, (uint16_t)sizeof(ncm_song_t) }; + int ok = (fwrite(&h, 1, sizeof h, f) == sizeof h) && + (fwrite(s, 1, sizeof(ncm_song_t), f) == sizeof(ncm_song_t)); + fclose(f); + if (!ok) remove(path); /* 半截文件不留(下次判 miss 重拉) */ + return ok ? NCM_OK : NCM_ERR_IO; +} diff --git a/patch_v2/ncm_app/app/ncm_app_internal.h b/patch_v2/ncm_app/app/ncm_app_internal.h index 0573604..58b3369 100644 --- a/patch_v2/ncm_app/app/ncm_app_internal.h +++ b/patch_v2/ncm_app/app/ncm_app_internal.h @@ -41,15 +41,17 @@ /* ---- 数据规模(内存纪律:歌单/歌曲大列表虚拟化显示,数组有界;硬约束 R3P2 ~14MB)---- */ #define NCM_PLAYLIST_MAX 120 /* 我的歌单上限 */ -/* 单个歌单曲目上限 = 分页驱动下的「单页渲染上限」(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 分页加载) */ +/* 滑动窗口(REPORT §17,三期第三批,取代 §14 的 NCM_SONG_MAX=1000 硬截断): + * song_ids[NCM_TRACKID_MAX] = detail(/v6/playlist/detail, n=1)取回的全量 trackIds + * (int64×16384=128KB bss,支撑任意长度歌单,不再 1000 截断); + * songs[NCM_SONG_WINDOW] = 物化滑动窗口(256 首 × ~608B ≈ 152KB bss,与歌单长度无关); + * song_valid[NCM_SONG_WINDOW]= 槽位有效位图(songs[i] ↔ 全局下标 window_base+i); + * 滚动出窗的槽位失效(数据已序列化到 /meta/.bin),入窗经 song_detail 按需 + * 拉取(盘缓存命中免请求)。列表 count = track_ids_count(全量),占位行驱动后台 ensure。 */ +#define NCM_TRACKID_MAX 16384 /* 全量 trackIds 索引上限(128KB bss) */ +#define NCM_SONG_WINDOW 256 /* 滑动窗口大小(内存物化歌曲对象数上限) */ #define NCM_SONG_BATCH 100 /* song_detail 分批每批条数(≤500 安全窗内取保守 100; - * 同时作「下一页」加载粒度;c 数组缓冲 clist[4096] 装 100 首 ≈2KB */ + * 窗口补载粒度;c 数组缓冲 clist[4096] 装 100 首 ≈2KB) */ #define NCM_LYRIC_LINES 600 /* 歌词行上限 */ #define NCM_COVER_MAX 288*1024 /* 封面下载限幅 ~288KB */ #define NCM_SEARCH_MAX 32 /* 搜索结果上限(v1 只显示前 32 条,无大缓冲) */ @@ -84,6 +86,10 @@ typedef struct ncm_play_run { int err; /* 最近一次播放错误(0=无) */ char errmsg[96]; int has_track; /* 当前是否有可播曲目(含 url 获取中) */ + /* 播放曲快照(REPORT §17):run_song 起播时把歌曲对象整体拷入(mu 保护), + * 之后播放/歌词/IPC/UI 全读快照而非窗口——窗口滑出当前播放曲也不影响播放。 */ + ncm_song_t play_song; + int play_song_valid; /* 1=快照有效(stop 清 0) */ } ncm_play_run_t; /* ---- 音质档位字符串(设置页 + 持久化用)---- */ @@ -113,14 +119,19 @@ typedef struct ncm_app_state { int active_playlist; /* 当前打开歌单的下标;-1=无 */ /* 当前歌单曲目(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]; + * 滑动窗口模型(REPORT §17): + * song_ids[] = detail 返回的全量 trackIds(≤ NCM_TRACKID_MAX,全局下标索引); + * songs[] = 物化滑动窗口,songs[i] ↔ 全局下标 window_base+i; + * song_valid[] = 槽位有效位图(出窗=失效,入窗=物化后置位); + * window_base = 窗口起始全局下标;song_count = 窗口内有效槽位数(≤ NCM_SONG_WINDOW); + * track_ids_count = trackIds 总数(列表 count = track_ids_count,任意长度无截断)。 + * 窗口读写由 appstate.c 内 g_song_mu 保护;外部一律走 appstate_song_copy/ensure_range。 */ + int64_t song_ids[NCM_TRACKID_MAX]; + ncm_song_t songs[NCM_SONG_WINDOW]; + uint8_t song_valid[NCM_SONG_WINDOW]; + int window_base; int song_count; - int track_ids_count; /* detail 取回的 trackIds 总数(≤ NCM_SONG_MAX) */ + int track_ids_count; /* detail 取回的 trackIds 总数(≤ NCM_TRACKID_MAX) */ /* 播放页视图态 */ int playing_song; /* 正在播放歌曲在 songs[] 下标(UI 侧缓存);-1=无 */ @@ -258,12 +269,24 @@ void appstate_set_logged_in(const char *uid, const char *nickname); void appstate_logout(void); int appstate_ensure_uid(void); /* 若 uid 缺失则 login_status_check 取回;0=成功 */ int appstate_load_playlists(void); /* 拉我的歌单 */ -int appstate_load_playlist_songs(int idx); /* 兼容:整批载入(内部走分页循环) */ +int appstate_load_playlist_songs(int idx); /* 兼容:载索引 + 首个窗口(内部走 ensure) */ int appstate_load_playlist_songs_page(int idx, int offset, int limit); - /* detail(全量 trackIds)后按页拉 song_detail: - * offset=0 重头(重建 song_ids[]/track_ids_count,清空后载第 0 页); - * offset>0 追加第 offset 页到 g_st.songs[offset..]; - * 写 g_st.song_count(=offset+本页实际数)。 */ + /* 兼容入口(滑动窗口语义,REPORT §17): + * offset=0 重头(detail 全量 trackIds 重建索引、清窗口、 + * 物化 [0,limit));offset>0 = 物化 [offset,offset+limit) + * (滑窗+盘缓存+song_detail 补载,内部 ensure_range)。 */ +int appstate_song_ensure_range(int lo, int hi); + /* 滑动窗口核心:确保全局下标 [lo,hi) 已物化。 + * 窗口未覆盖先滑窗(重叠区保留,出窗槽位失效); + * 缺失槽位先读盘缓存(/meta/.bin), + * miss 攒批 ≤NCM_SONG_BATCH 走 song_detail,成功落盘。 + * worker 线程调用(含网络);返回 NCM_OK/负错误码。 */ +int appstate_range_loaded(int lo, int hi); /* 1=[lo,hi) 全部在窗内且有效(UI 判是否需 ensure) */ +int appstate_song_copy(int gidx, ncm_song_t *out); /* 锁内读窗:0=已物化拷出;-1=窗外/未物化 */ +int appstate_song_fetch(int gidx, ncm_song_t *out); /* 任意全局下标物化(窗→盘→song_detail 单曲), + * 不滑窗(播放线程用,避免与浏览视口抢窗口) */ +void appstate_adopt_songs(int n); /* 收编外部已填的 songs[0..n)(搜索结果)为窗口 base=0, + * 同步 song_ids/track_ids_count(播放上下文) */ void appstate_apply_playlist_cover(int idx); void appstate_set_playing_song(int idx, const char *cover_url); int appstate_current_playing(void); /* 返回当前播放歌曲下标(UI) */ @@ -311,6 +334,12 @@ int64_t player_manager_pos_ms(void); int64_t player_manager_dur_ms(void); int player_manager_load_lyric(void); /* 为当前曲目拉歌词并解析 */ int player_manager_error(void); +int player_manager_current_song(ncm_song_t *out); /* 播放曲快照锁内拷贝;0=有效 -1=无(REPORT §17) */ + +/* songmeta(app/logic/songmeta.c):歌曲元数据本地缓存(/meta/.bin), + * 拉过的 song_detail 结果序列化落盘,再次入窗免重复请求(REPORT §17)。 */ +int songmeta_load(const char *cache_dir, const char *song_id, ncm_song_t *out); /* NCM_OK / NCM_ERR_NOENT */ +int songmeta_save(const char *cache_dir, const char *song_id, const ncm_song_t *s); /* ipc_media:媒体会话上报/收指令 */ int ipc_media_start(void); /* 起 IPC 线程;0=成功 */ diff --git a/patch_v2/ncm_app/app/play/ipc_media.c b/patch_v2/ncm_app/app/play/ipc_media.c index 78a66f4..e8fd48b 100644 --- a/patch_v2/ncm_app/app/play/ipc_media.c +++ b/patch_v2/ncm_app/app/play/ipc_media.c @@ -108,10 +108,11 @@ static void send_meta(int fd, uint32_t req_id) static char title[128], artist[128], album[128]; title[0] = artist[0] = album[0] = 0; - if (idx >= 0 && idx < g_st.song_count) { - snprintf(title, sizeof title, "%s", g_st.songs[idx].name); - snprintf(artist, sizeof artist, "%s", g_st.songs[idx].art_name); - snprintf(album, sizeof album, "%s", g_st.songs[idx].album_name); + ncm_song_t sm; + if (idx >= 0 && player_manager_current_song(&sm) == 0) { /* 播放快照(窗口无关,§17) */ + snprintf(title, sizeof title, "%s", sm.name); + snprintf(artist, sizeof artist, "%s", sm.art_name); + snprintf(album, sizeof album, "%s", sm.album_name); } snprintf(mm.title, sizeof mm.title, "%s", title); snprintf(mm.artist, sizeof mm.artist, "%s", artist); diff --git a/patch_v2/ncm_app/app/play/player_manager.c b/patch_v2/ncm_app/app/play/player_manager.c index ad1994f..5977352 100644 --- a/patch_v2/ncm_app/app/play/player_manager.c +++ b/patch_v2/ncm_app/app/play/player_manager.c @@ -41,6 +41,18 @@ int64_t player_manager_pos_ms(void) { int64_t p; pthread_mutex_lock(&g_play.mu); int64_t player_manager_dur_ms(void) { int64_t d; pthread_mutex_lock(&g_play.mu); d = g_play.dur_ms; pthread_mutex_unlock(&g_play.mu); return d; } int player_manager_error(void) { int e; pthread_mutex_lock(&g_play.mu); e = g_play.err; pthread_mutex_unlock(&g_play.mu); return e; } +/* 播放曲快照(REPORT §17):run_song 起播时物化并拷入 g_play.play_song(mu 保护), + * 之后播放/歌词/IPC/UI 一律读快照——窗口滑出当前播放曲不影响播放,不许崩。 */ +int player_manager_current_song(ncm_song_t *out) +{ + if (!out) return -1; + pthread_mutex_lock(&g_play.mu); + int ok = g_play.play_song_valid; + if (ok) *out = g_play.play_song; + pthread_mutex_unlock(&g_play.mu); + return ok ? 0 : -1; +} + void player_manager_set_quality(int level, int cache_gb) { g_level = level; @@ -83,7 +95,7 @@ void player_manager_next(int dir) pthread_mutex_lock(&g_play.mu); int c = g_play.cur_song + dir; if (c < 0) c = 0; - if (c >= g_st.song_count) c = g_st.song_count - 1; + if (c >= g_st.track_ids_count) c = g_st.track_ids_count - 1; /* 滑窗:边界=全量索引数 */ if (c != g_play.cur_song) { g_play.cur_song = c; g_play.cmd = 0; @@ -100,6 +112,7 @@ void player_manager_stop(void) g_play.cur_song = -1; g_play.state = NCM_PC_STOPPED; g_play.pos_ms = 0; + g_play.play_song_valid = 0; /* 快照失效 */ g_play.seq++; pthread_mutex_unlock(&g_play.mu); } @@ -111,12 +124,9 @@ static int download_cb(void *ctx, const unsigned char *data, size_t len) } /* 得到本地 mp3 路径 + 文件大小。命中缓存直接返回;否则 URL 降级 + 流式落缓存。 - * 返回 NCM_OK 或负错误码。 */ -static int song_local_file(int idx, char *path, size_t psz, int64_t *bytes) + * 返回 NCM_OK 或负错误码。id 由调用方(run_song)从播放曲快照取,与窗口解耦(§17)。 */ +static int song_local_file(const char *id, char *path, size_t psz, int64_t *bytes) { - const ncm_song_t *s = &g_st.songs[idx]; - const char *id = s->id; - /* 缓存命中 → 离线直接播 */ if (ncm_cache_lookup(&g_st.ctx.cache, id, ".mp3", path, psz) == NCM_OK) { struct stat st; @@ -176,9 +186,21 @@ static void set_err(int err, const char *msg) /* 一首歌:下载→解码→输出,直到结束或被打断。返回后由外层处理下一首。 */ static void run_song(int idx) { + /* §17 滑窗兼容:物化目标曲(窗内命中 / 盘缓存 / song_detail 单曲)并整体快照进 + * g_play.play_song——之后本曲所有字段读快照,窗口滑出当前播放曲不影响播放。 */ + ncm_song_t s; + if (appstate_song_copy(idx, &s) != 0) { + int fr = appstate_song_fetch(idx, &s); + if (fr != NCM_OK) { set_err(fr, "load failed"); return; } + } + pthread_mutex_lock(&g_play.mu); + g_play.play_song = s; + g_play.play_song_valid = 1; + pthread_mutex_unlock(&g_play.mu); + int64_t file_bytes = 0; char path[512]; - int rc = song_local_file(idx, path, sizeof path, &file_bytes); + int rc = song_local_file(s.id, path, sizeof path, &file_bytes); if (rc != NCM_OK) { const char *m = (rc == NCM_ERR_NOTFOUND) ? "no stream (VIP/copyright)" : (rc == NCM_ERR_AUTH) ? "session expired" : "load failed"; @@ -188,8 +210,7 @@ static void run_song(int idx) ncm_strlcpy(g_path, path, sizeof g_path); g_file_bytes = file_bytes; - const ncm_song_t *s = &g_st.songs[idx]; - int64_t dur = s->duration_ms > 0 ? s->duration_ms : 0; + int64_t dur = s.duration_ms > 0 ? s.duration_ms : 0; mp3dec_ex_t dec; memset(&dec, 0, sizeof dec); @@ -205,7 +226,7 @@ static void run_song(int idx) g_play.dur_ms = dur; g_play.err = 0; pthread_mutex_unlock(&g_play.mu); - meow_log(MEOW_LOG_INFO, "play: start idx=%d id=%s dur=%lldms", idx, s->id, (long long)dur); + meow_log(MEOW_LOG_INFO, "play: start idx=%d id=%s dur=%lldms", idx, s.id, (long long)dur); mp3d_sample_t pcm[PCM_CHUNK]; int64_t frames_done = 0; /* 已输出帧(每通道) */ @@ -291,7 +312,7 @@ static void *player_thread(void *arg) int st = g_play.state; pthread_mutex_unlock(&g_play.mu); - if (cur < 0 || cur >= g_st.song_count || st == NCM_PC_STOPPED) { + if (cur < 0 || cur >= g_st.track_ids_count || st == NCM_PC_STOPPED) { usleep(60 * 1000); continue; } @@ -303,9 +324,9 @@ static void *player_thread(void *arg) int st2 = g_play.state; pthread_mutex_unlock(&g_play.mu); /* 自然播完且无新命令 → 顺延下一首;否则回到循环等待 */ - if (cur2 >= 0 && cur2 < g_st.song_count && st2 != NCM_PC_STOPPED && cur2 == cur) { + if (cur2 >= 0 && cur2 < g_st.track_ids_count && st2 != NCM_PC_STOPPED && cur2 == cur) { int nx = cur + 1; - if (nx < g_st.song_count) { player_manager_next(1); } + if (nx < g_st.track_ids_count) { player_manager_next(1); } else { player_manager_stop(); } } else { usleep(50 * 1000); @@ -316,23 +337,27 @@ static void *player_thread(void *arg) /* ---- 歌词后台加载(二期):UI 不阻塞 —— 持久 worker 监视当前播放歌曲,首播/切歌/自动顺延 * 时在独立线程拉取并解析,UI 经 lyric_state() 判「加载中 / 无词」。 ---- - * 现存 race(§10.9 FM4,UI 读 lyrics 与 worker 写)为良性:数组定长、越界已防护, - * 最坏显示一帧旧/空行,下一 tick 自愈;与 g_st.songs 写读竞态同接受。 */ + * §17:曲目 id 改读播放快照(g_play.play_song,mu 保护),不再依赖窗口内 + * g_st.songs[]——窗口滑出当前播放曲歌词照常。 */ static pthread_mutex_t g_lyrmu = PTHREAD_MUTEX_INITIALIZER; static pthread_t g_lyr_thr; static int g_lyr_thr_on; static volatile int g_lyr_state; /* 0=空闲/未拉 1=加载中 2=已拉完 */ -static char g_lyr_song_id[32]; /* 歌词归属歌曲 id */ +static char g_lyr_song_id[24]; /* 歌词归属歌曲 id */ static void *lyric_worker(void *arg) { (void)arg; - char last_id[32] = ""; + char last_id[24] = ""; while (!g_app_quit) { usleep(300 * 1000); - int idx; - pthread_mutex_lock(&g_play.mu); idx = g_play.cur_song; pthread_mutex_unlock(&g_play.mu); - if (idx < 0 || idx >= g_st.song_count) { + char id[24]; + pthread_mutex_lock(&g_play.mu); + int idx = g_play.cur_song; + int have = g_play.play_song_valid; + ncm_strlcpy(id, have ? g_play.play_song.id : "", sizeof id); + pthread_mutex_unlock(&g_play.mu); + if (idx < 0 || idx >= g_st.track_ids_count || !have) { lyric_clear(); pthread_mutex_lock(&g_lyrmu); g_lyr_song_id[0] = 0; @@ -341,7 +366,6 @@ static void *lyric_worker(void *arg) last_id[0] = 0; continue; } - const char *id = g_st.songs[idx].id; if (!id[0]) continue; if (strcmp(id, last_id) == 0) continue; /* 同一曲已拉/在拉 */ diff --git a/patch_v2/ncm_app/app/test/test_ncm_app.c b/patch_v2/ncm_app/app/test/test_ncm_app.c index 672fb90..771f60d 100644 --- a/patch_v2/ncm_app/app/test/test_ncm_app.c +++ b/patch_v2/ncm_app/app/test/test_ncm_app.c @@ -42,7 +42,9 @@ static void mock_songs(void) "Song Four", "Song Five", "Song Six" }; static const char *ar[] = { "Artist A", "Artist B", "Artist C", "Artist D", "Artist E", "Artist F" }; - g_st.song_count = 6; + g_st.song_count = 6; /* 滑窗模型:窗口 base=0,6 槽全有效(§17) */ + g_st.window_base = 0; + g_st.track_ids_count = 6; g_st.active_playlist = 0; for (int i = 0; i < 6; i++) { snprintf(g_st.songs[i].id, sizeof g_st.songs[i].id, "%ld", 1000L + i); @@ -51,6 +53,8 @@ static void mock_songs(void) snprintf(g_st.songs[i].album_name, sizeof g_st.songs[i].album_name, "Album %d", i + 1); g_st.songs[i].duration_ms = 180000 + i * 20000; g_st.songs[i].fee = (i == 3) ? 1 : 0; + g_st.song_valid[i] = 1; + g_st.song_ids[i] = 1000 + i; } ncm_strlcpy(g_st.cover_url, "http://p1.music.126.net/cover0.jpg", sizeof g_st.cover_url); g_st.playing_song = 0; @@ -139,6 +143,8 @@ int main(int argc, char **argv) g_play.cur_song = 0; g_play.pos_ms = 94000; g_play.dur_ms = 180000; + g_play.play_song = g_st.songs[0]; /* 播放快照(§17) */ + g_play.play_song_valid = 1; pthread_mutex_unlock(&g_play.mu); } else if (strcmp(name, "settings") == 0) { g_st.logged_in = 1; @@ -162,8 +168,11 @@ int main(int argc, char **argv) snprintf(g_st.songs[i].album_name, sizeof g_st.songs[i].album_name, "%s", al[i]); g_st.songs[i].duration_ms = 180000 + i * 20000; g_st.songs[i].fee = 0; + g_st.song_valid[i] = 1; + g_st.song_ids[i] = 1000 + i; } g_st.song_count = 3; + g_st.track_ids_count = 3; } else { mock_playlists(); } diff --git a/patch_v2/ncm_app/app/ui/player.c b/patch_v2/ncm_app/app/ui/player.c index fedded5..d32813b 100644 --- a/patch_v2/ncm_app/app/ui/player.c +++ b/patch_v2/ncm_app/app/ui/player.c @@ -25,6 +25,7 @@ static ncm_rect_t r_cont; static int g_custom_disc, g_custom_dyn; static int g_landscape; static int g_last_pos_ms, g_last_state, g_last_line, g_last_song; +static char g_last_meta_id[24]; /* 已上封面的元数据 id(快照滞后 settle 时纠正封面/歌名) */ /* 运行期几何(player_compute_geom 填充;各带绘制物/命中共用)。 */ static ncm_rect_t g_disc_r, g_dyn_r; /* 盘带 / 动态带 bbox(不重叠) */ @@ -40,7 +41,16 @@ static ncm_rect_t g_lyr; /* 歌词区 */ static int cur_song(void) { int c = player_manager_playing_song(); - return (c >= 0 && c < g_st.song_count) ? c : -1; + return (c >= 0 && c < g_st.track_ids_count) ? c : -1; +} + +/* 当前播放曲元数据(§17):优先播放快照(窗口无关);快照未就绪时退窗口拷贝。 */ +static int cur_song_meta(ncm_song_t *out) +{ + if (player_manager_current_song(out) == 0) return 0; + int sc = cur_song(); + if (sc >= 0 && appstate_song_copy(sc, out) == 0) return 0; + return -1; } static void fmt_ms(int64_t ms, char *out, size_t sz) @@ -188,10 +198,10 @@ static void plr_draw_dyn(void *ctx) if (meow_ui_activity_active() != ACT_PLAYER) return; const meow_theme *th = meow_theme_get(); const ncm_pal_t *pa = ncm_palette(g_dark); - int sc = cur_song(); - const ncm_song_t *s = (sc >= 0) ? &g_st.songs[sc] : NULL; - const char *nm = s ? s->name : "暂无播放"; - const char *ar = s ? s->art_name : ""; + ncm_song_t sm; + int have = (cur_song_meta(&sm) == 0); + const char *nm = have ? sm.name : "暂无播放"; + const char *ar = have ? sm.art_name : ""; if (g_landscape) { meow_ui_text(g_info_x, g_title_y, ncm_text_fit(nm, g_info_w, 28), 28, th->onSurface); @@ -204,7 +214,7 @@ static void plr_draw_dyn(void *ctx) /* 进度条 + 时间 */ int pos = (int)player_manager_pos_ms(); int dur = (int)player_manager_dur_ms(); - if (dur <= 0 && sc >= 0) dur = (int)(g_st.songs[sc].duration_ms); + if (dur <= 0 && have) dur = (int)sm.duration_ms; int frac = dur > 0 ? (int)((int64_t)pos * 1000 / dur) : 0; if (frac < 0) frac = 0; if (frac > 1000) frac = 1000; char lp[16], rp[16]; @@ -302,11 +312,12 @@ void player_on_tap(int x, int y) void player_play_song(int idx) { - if (idx < 0 || idx >= g_st.song_count) return; - /* 二期:黑胶封面用真实专辑图(song_detail 的 al.picUrl,经 ncm_cache 缓存/限幅), - * 无 al.picUrl(如无版权占位)时回落歌单封面。 */ - const ncm_song_t *s = &g_st.songs[idx]; - const char *cover = (s && s->al_pic[0]) ? s->al_pic : g_st.cover_url; + if (idx < 0 || idx >= g_st.track_ids_count) return; + /* 黑胶封面用真实专辑图(song_detail 的 al.picUrl,经 ncm_cache 缓存/限幅); + * 滑窗未物化(占位行直点)时回落歌单封面,元数据由播放线程物化后快照接管(§17)。 */ + ncm_song_t s; + const char *cover = (appstate_song_copy(idx, &s) == 0 && s.al_pic[0]) + ? s.al_pic : g_st.cover_url; player_manager_play(idx); appstate_set_playing_song(idx, cover); cover_request(cover); @@ -338,13 +349,16 @@ void player_tick(uint64_t now) if (sc != g_last_song) { g_last_song = sc; - /* 切歌(next/prev/自动顺延)→ 请求新曲专辑封面(黑胶盘换图) */ - if (sc >= 0) { - const ncm_song_t *s2 = &g_st.songs[sc]; - cover_request(s2->al_pic[0] ? s2->al_pic : g_st.cover_url); - } meow_ui_invalidate(g_dyn_r.x, g_dyn_r.y, g_dyn_r.w, g_dyn_r.h); /* 歌名/歌手变 */ } + /* 元数据 id 变化(含快照滞后 settle:切歌瞬间快照仍是旧曲,play 线程更新后此处纠正)→ + * 请求新曲专辑封面(黑胶盘换图)+ 刷动态带。 */ + ncm_song_t s2; + if (sc >= 0 && cur_song_meta(&s2) == 0 && strcmp(s2.id, g_last_meta_id) != 0) { + ncm_strlcpy(g_last_meta_id, s2.id, sizeof g_last_meta_id); + cover_request(s2.al_pic[0] ? s2.al_pic : g_st.cover_url); + meow_ui_invalidate(g_dyn_r.x, g_dyn_r.y, g_dyn_r.w, g_dyn_r.h); + } if (cover_changed()) { cover_ack(); meow_ui_invalidate(g_disc_r.x, g_disc_r.y, g_disc_r.w, g_disc_r.h); @@ -376,6 +390,7 @@ static void plr_page_create(meow_view *v, void *ctx) g_custom_dyn = meow_draw_custom_register(11, g_dyn_r.x, g_dyn_r.y, g_dyn_r.w, g_dyn_r.h, plr_draw_dyn, NULL); g_last_pos_ms = -1; g_last_state = -1; g_last_line = -1; g_last_song = -1; + g_last_meta_id[0] = 0; } static void plr_page_enter(meow_view *v, void *ctx) diff --git a/patch_v2/ncm_app/app/ui/playlist.c b/patch_v2/ncm_app/app/ui/playlist.c index 31d3161..5c84262 100644 --- a/patch_v2/ncm_app/app/ui/playlist.c +++ b/patch_v2/ncm_app/app/ui/playlist.c @@ -25,7 +25,9 @@ static char g_title[256]; static volatile int g_load_err; static int g_press_row, g_press_delta, g_dragging, g_drag_last_y; -/* 歌单详情加载后台 worker(大歌单可达数十秒,不能占 UI 线程)。 */ +/* 歌单详情加载后台 worker(大歌单可达数十秒,不能占 UI 线程)。 + * §17 滑动窗口:worker 两种任务——①首屏(g_ens_first=1):重建 trackIds 索引 + 物化首段; + * ②滑窗补载:物化视口区间 [g_ens_lo,g_ens_hi)(ensure_range,盘缓存优先)。 */ static pthread_mutex_t g_plmu = PTHREAD_MUTEX_INITIALIZER; static pthread_t g_plthr; static int g_plthr_on; @@ -33,8 +35,9 @@ static volatile int g_plgen; 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=尚有下一页可加载(song_count < track_ids_count 且未到 NCM_SONG_MAX) */ +static volatile int g_ens_lo, g_ens_hi; /* 滑窗补载目标区间(全局下标) */ +static volatile int g_ens_first; /* 1=首屏重头(offset=0) */ +static volatile int g_ens_err; /* 最近一次滑窗补载错误(0=ok;首屏错误走 g_load_err) */ static void pl_drag_reset(void) { @@ -44,15 +47,24 @@ static void pl_drag_reset(void) static int pl_src2(int idx, meow_list_item *out, void *ctx) { (void)ctx; - if (idx < 0 || idx >= g_st.song_count) return 0; - ncm_song_t *s = &g_st.songs[idx]; + if (idx < 0 || idx >= g_st.track_ids_count) return 0; + static ncm_song_t s_s; /* 锁内拷贝驻留缓冲(src2 由 UI 线程串行调用,title 指针对当次绘制有效) */ + if (appstate_song_copy(idx, &s_s) != 0) { + /* 占位行:滑窗未覆盖/未物化(后台 ensure 完成后自愈)。 */ + out->icon_kind = MEOW_LIST_ICON_NONE; + out->title = "···"; + out->subtitle = "加载中"; + out->right = MEOW_LIST_RIGHT_ARROW; + out->on = 0; out->enabled = 1; + return 1; + } static char sub[256]; - if (s->art_name[0] && s->album_name[0]) snprintf(sub, sizeof sub, "%s · %s", s->art_name, s->album_name); - else if (s->art_name[0]) snprintf(sub, sizeof sub, "%s", s->art_name); - else if (s->album_name[0]) snprintf(sub, sizeof sub, "%s", s->album_name); - else snprintf(sub, sizeof sub, "-"); + if (s_s.art_name[0] && s_s.album_name[0]) snprintf(sub, sizeof sub, "%s · %s", s_s.art_name, s_s.album_name); + else if (s_s.art_name[0]) snprintf(sub, sizeof sub, "%s", s_s.art_name); + else if (s_s.album_name[0]) snprintf(sub, sizeof sub, "%s", s_s.album_name); + else snprintf(sub, sizeof sub, "-"); out->icon_kind = MEOW_LIST_ICON_NONE; - out->title = s->name; + out->title = s_s.name; out->subtitle = sub; out->right = MEOW_LIST_RIGHT_ARROW; out->on = 0; out->enabled = 1; @@ -64,12 +76,12 @@ static void pl_row_bg(void *ctx) { (void)ctx; if (meow_ui_activity_active() != ACT_PLAYLIST) return; - if (g_load_err != 0 || g_st.song_count == 0) return; + if (g_load_err != 0 || g_st.track_ids_count == 0) return; const meow_theme *th = meow_theme_get(); int item = APP_ROW_H; int off = meow_listview_scroll(g_list); int first = off / item, last = (off + r_list.h) / item + 1; - if (last > g_st.song_count) last = g_st.song_count; + if (last > g_st.track_ids_count) last = g_st.track_ids_count; for (int i = first; i < last; i++) { int ry = r_list.y + (i * item - off) + 1, th2 = item - 2; if (ry < r_list.y || ry + th2 > r_list.y + r_list.h) continue; /* 只画完整落在视口内的行 */ @@ -100,7 +112,7 @@ static void pl_draw(void *ctx) int nmx = r_cov.x + r_cov.w + 12; char meta[256]; if (pl) snprintf(meta, sizeof meta, "创建者 · %d 首", pl->track_count); - else snprintf(meta, sizeof meta, "曲目 %d 首", g_st.song_count); + else snprintf(meta, sizeof meta, "曲目 %d 首", g_st.track_ids_count); /* 歌单名(截断到卡片右缘:按 UTF-8 字符合法边界逐字累加测宽)。 */ { int maxw = r_card.x + r_card.w - 16 - nmx; @@ -131,11 +143,11 @@ static void pl_draw(void *ctx) meow_ui_fill_rect(r_cmt.x + r_cmt.w / 2 + 34, r_cmt.y + 10, 6, 6, pa->red); /* 曲目行:红 mini-vinyl 缩略 + 按压红条(文本由 listview z=10 画) */ - if (g_load_err == 0 && g_st.song_count > 0) { + if (g_load_err == 0 && g_st.track_ids_count > 0) { int item = APP_ROW_H, isz = 24; int off = meow_listview_scroll(g_list); int first = off / item, last = (off + r_list.h) / item + 1; - if (last > g_st.song_count) last = g_st.song_count; + if (last > g_st.track_ids_count) last = g_st.track_ids_count; for (int i = first; i < last; i++) { int ry = r_list.y + (i * item - off) + 1, th2 = item - 2; if (ry < r_list.y || ry + th2 > r_list.y + r_list.h) continue; @@ -154,7 +166,7 @@ static void pl_draw_empty(void *ctx) { (void)ctx; if (meow_ui_activity_active() != ACT_PLAYLIST) return; - if (g_load_err == 0 && g_st.song_count != 0) return; + if (g_load_err == 0 && g_st.track_ids_count != 0) return; if (g_plloading) { ncm_draw_empty(r_list, "加载中", "正在加载曲目..."); return; } if (g_load_err == NCM_ERR_AUTH) ncm_draw_empty(r_list, "登录已过期", "请重新登录"); else if (g_load_err) ncm_draw_empty(r_list, "加载失败", "点按重试"); @@ -166,18 +178,22 @@ static void pl_draw_footer(void *ctx) (void)ctx; if (meow_ui_activity_active() != ACT_PLAYLIST) return; if (g_load_err != 0) return; - int total = (g_st.active_playlist >= 0 && g_st.active_playlist < g_st.playlist_count) - ? g_st.playlists[g_st.active_playlist].track_count : g_st.song_count; + int total = g_st.track_ids_count; + if (total <= 0) return; + /* 视口位置 / 总数(滑窗模型下「已物化数」无用户意义,视口区间更直观) */ + int first = meow_listview_scroll(g_list) / APP_ROW_H + 1; + int last = first + r_list.h / APP_ROW_H; + if (last > total) last = total; char foot[96]; - snprintf(foot, sizeof foot, "%d/%d 首", g_st.song_count, total); + snprintf(foot, sizeof foot, "%d-%d / %d 首", first, last, total); ncm_draw_footer(r_cont, foot); } -/* apply:reset_scroll=1 时列表回顶并清选中(首屏/刷新);0=保持当前滚动(触底追加,防跳顶)。 */ +/* apply:reset_scroll=1 时列表回顶并清选中(首屏/刷新);0=保持当前滚动(滑窗补载,防跳顶)。 */ static void pl_apply(int reset_scroll) { if (g_list) { - meow_listview_set_count(g_list, g_st.song_count); + meow_listview_set_count(g_list, g_st.track_ids_count); /* 全量行数(占位行兜底未物化区) */ if (reset_scroll) { meow_listview_set_scroll(g_list, 0); meow_listview_set_sel(g_list, -1); } } if (g_tb && g_st.active_playlist >= 0 && g_st.active_playlist < g_st.playlist_count) { @@ -193,16 +209,16 @@ static void *playlist_worker(void *arg) { (void)arg; int my_idx = g_st.active_playlist; - int off = g_ploffset; + int first = g_ens_first; + int lo = g_ens_lo, hi = g_ens_hi; 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_SONG_BATCH); + else if (first) rc = appstate_load_playlist_songs_page(my_idx, 0, NCM_SONG_BATCH); + else rc = appstate_song_ensure_range(lo, hi); pthread_mutex_lock(&g_plmu); - g_load_err = rc; - /* 本页后仍有多余 trackIds(tail 未到)且未到内存上限 → 还有下一页;否则到屏尾。 */ - g_pl_more = (rc == NCM_OK) && (g_st.song_count < g_st.track_ids_count) && - (g_st.song_count < NCM_SONG_MAX); + if (first) g_load_err = rc; /* 首屏失败 → 整页空态(点按重试) */ + else g_ens_err = rc; /* 补载失败 → snack + 占位行保留(滚动/点按再试) */ g_plloading = 0; g_plloaded_idx = my_idx; g_plgen++; @@ -210,19 +226,23 @@ static void *playlist_worker(void *arg) return NULL; } -/* 触底加载下一页:仅当仍有下一页、未在载、未失败、未到 NCM_SONG_MAX 时发。 */ -static void pl_maybe_load_more(void) +/* 视口驱动滑窗补载:视口 ±24 行有未物化槽位 → 发 ensure worker(已在载/失败态/宿主 mock 不发)。 */ +static void pl_maybe_ensure(void) { 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; - /* 视口底接近列表底(最后一行即将可见即预取) */ - int list_px = g_st.song_count * APP_ROW_H; - 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; /* 下一页起始 = 已物化数 */ + if (g_load_err != 0) return; /* 首屏失败态:走点按重试 */ + int total = g_st.track_ids_count; + if (total <= 0) return; + int scroll = meow_listview_scroll(g_list); + int first = scroll / APP_ROW_H; + int last = (scroll + r_list.h) / APP_ROW_H + 1; + int lo = first - 24, hi = last + 24; + if (lo < 0) lo = 0; + if (hi > total) hi = total; + if (lo >= hi) return; + if (appstate_range_loaded(lo, hi)) return; /* 已物化:无操作(窗口原地不动) */ + g_ens_lo = lo; g_ens_hi = hi; + g_ens_first = 0; g_plloading = 1; g_plthr_on = 1; if (pthread_create(&g_plthr, NULL, playlist_worker, NULL) != 0) { @@ -232,13 +252,11 @@ static void pl_maybe_load_more(void) void playlist_refresh(void) { - if (g_host_mock) { g_load_err = 0; g_pl_more = 0; pl_apply(1); return; } + if (g_host_mock) { g_load_err = 0; g_ens_err = 0; pl_apply(1); return; } 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_ens_err = 0; + g_ens_first = 1; /* 首屏:重建索引 + 物化首段 */ g_plloading = 1; g_plthr_on = 1; if (pthread_create(&g_plthr, NULL, playlist_worker, NULL) != 0) { @@ -257,20 +275,15 @@ 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; /* 首屏失败 → 整页空态(点按重试) */ - 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); - } + g_st.track_ids_count = 0; /* 首屏失败 → 整页空态(点按重试) */ + pl_apply(1); return; } - pl_apply(g_ploffset == 0); /* 首屏回顶;触底追加保持滚动 */ + if (g_ens_err != 0) { + ncm_snack("加载失败"); + g_ens_err = 0; /* 占位行保留:滚动/点按触发重试 */ + } + pl_apply(g_ens_first); } void playlist_open(int idx) @@ -307,7 +320,7 @@ int playlist_on_tap(int x, int y) playlist_refresh(); return 1; } - if (g_st.song_count == 0) return 0; /* 空歌单(无失败):不重试 */ + if (g_st.track_ids_count == 0) return 0; /* 空歌单(无失败):不重试 */ if (meow_widget_hit(g_list, x, y)) { /* 误触修复:小拖动(= 0) ? g_press_row : meow_listview_hit(g_list, x, y); pl_drag_reset(); if (row < 0 || was_drag) return 1; - player_play_song(row); + player_play_song(row); /* 占位行也可点:播放线程经 appstate_song_fetch 物化该曲 */ return 1; } return 0; @@ -326,7 +339,7 @@ int playlist_on_tap(int x, int y) void playlist_touch_down(int x, int y) { pl_drag_reset(); - if (g_load_err != 0 || g_st.song_count == 0) return; + if (g_load_err != 0 || g_st.track_ids_count == 0) return; if (meow_widget_hit(g_list, x, y)) { int row = meow_listview_hit(g_list, x, y); if (row >= 0) g_press_row = row; @@ -350,20 +363,20 @@ void playlist_touch_move(int x, int y) meow_listview_scroll_by(g_list, -dy); if (meow_listview_scroll(g_list) != before) { meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); - pl_maybe_load_more(); + pl_maybe_ensure(); } } void playlist_swipe(int dir, int dist) { if (dir != MEOW_SWIPE_UP && dir != MEOW_SWIPE_DOWN) return; - if (g_load_err != 0 || g_st.song_count == 0) return; + if (g_load_err != 0 || g_st.track_ids_count == 0) return; int del = (dir == MEOW_SWIPE_UP) ? dist : -dist; int before = meow_listview_scroll(g_list); meow_listview_scroll_by(g_list, del); if (meow_listview_scroll(g_list) != before) { meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); - pl_maybe_load_more(); + pl_maybe_ensure(); } } diff --git a/patch_v2/ncm_app/app/ui/search.c b/patch_v2/ncm_app/app/ui/search.c index 01657f9..651db42 100644 --- a/patch_v2/ncm_app/app/ui/search.c +++ b/patch_v2/ncm_app/app/ui/search.c @@ -94,7 +94,9 @@ static void sr_apply(void) meow_ui_paint_full(); } -/* 后台 worker:唯一网络调用(cloudsearch),UI 不卡。 */ +/* 后台 worker:唯一网络调用(cloudsearch),UI 不卡。 + * §17:结果集(≤NCM_SEARCH_MAX,远小于窗口)经 appstate_adopt_songs 收编为窗口 base=0 + * 播放上下文(song_ids/track_ids_count 同步),播放/上下一首对结果集生效。 */ static void *search_worker(void *arg) { (void)arg; @@ -104,12 +106,12 @@ static void *search_worker(void *arg) g_st.songs, NCM_SEARCH_MAX, &n); pthread_mutex_lock(&g_mu); if (rc == NCM_OK) { + appstate_adopt_songs(n); /* 收编 songs[0..n) 为窗口(写侧在锁内) */ g_st.search_count = n; - g_st.song_count = n; /* 结果集作为当前播放上下文 */ g_st.search_err = 0; } else { + appstate_adopt_songs(0); g_st.search_count = 0; - g_st.song_count = 0; g_st.search_err = rc; } g_loading = 0; diff --git a/patch_v2/ncm_app/bench/pagination_sim.c b/patch_v2/ncm_app/bench/pagination_sim.c index df7eab6..94655df 100644 --- a/patch_v2/ncm_app/bench/pagination_sim.c +++ b/patch_v2/ncm_app/bench/pagination_sim.c @@ -1,19 +1,23 @@ -/* bench/pagination_sim.c — 三期第一批 歌单分页 离线状态机验证(0 网络请求)。 +/* bench/pagination_sim.c — 三期第三批 滑动窗口 基本语义离线回归(0 网络请求)。 + * (§14 版为「分页 append 到 NCM_SONG_MAX=1000 截断」回归;§17 起分页被滑动窗口取代, + * 本文件随之改测兼容入口 appstate_load_playlist_songs_page 的新语义。) * * 直接驱动 App 的真实函数 appstate_load_playlist_songs_page()(app/logic/appstate.c), * 仅把两个网络端点(ncm_api_playlist_detail / ncm_api_song_detail)替换成本地 stub - * (合成 trackIds / 合成歌曲对象),验证 detail 后按页 song_detail 的累加/封顶/尾部语义: - * ① offset=0 重建索引(song_ids[]←全量 trackIds,track_ids_count)并载第 0 页; - * ② 逐页 offset 前进,song_count 单调累加; - * ③ 尾部(song_count==track_ids_count)不再载下一页;④ NCM_SONG_MAX 封顶不越界。 - * 风控纪律:本验证 0 次真实 NCM 请求(离线 stub)。歌曲上限 = NCM_SONG_MAX(内存封顶); - * 本模拟用两种歌单规模:(a) 130 首(< 上限,读全程);(b) 5191 首(> 上限,读满 NCM_SONG_MAX 截断)。 + * (合成 trackIds / 合成歌曲对象),验证: + * ① offset=0 重建索引(song_ids[]←全量 trackIds,track_ids_count)并物化首段; + * ② offset>0 物化 [offset,offset+limit)(滑窗 + song_detail 批补载); + * ③ 尾部(offset≥track_ids_count)无操作;④ 大歌单不再截断(索引全量 5191, + * 窗口内有效槽位 ≤ NCM_SONG_WINDOW);⑤ 占位/出窗槽 song_copy 失败、入窗成功。 + * 风控纪律:本验证 0 次真实 NCM 请求(离线 stub;cache_dir 置空 → songmeta 全部 miss/不落盘)。 * * 编译(宿主):$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 + * $CC_HOST -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ + * -Icore -I../vendor/jsmn -c app/logic/songmeta.c -o /tmp/ps_meta.o + * $CC_HOST -O2 -Wl,--gc-sections -o pagination_sim /tmp/ps.o /tmp/ps_app.o /tmp/ps_meta.o -lpthread * 运行:./pagination_sim (0 网络;PASS/FAIL 退出码) */ #include "../app/ncm_app_internal.h" @@ -21,7 +25,7 @@ #include #include -/* appstate.c 依赖的全局(app.c 提供;本 bench 只测分页路径,仅 g_st 被用到) */ +/* 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; @@ -49,7 +53,7 @@ int ncm_api_playlist_detail(const char *device_id, const char *cookie, const cha 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; + (void)device_id; (void)cookie; (void)cover_sz; (void)playlist_id; g_detail_calls++; if (name) snprintf(name, name_sz, "Sim Playlist"); if (cover) cover[0] = 0; @@ -75,7 +79,7 @@ int ncm_api_song_detail(const char *device_id, const char *cookie, const int64_t return NCM_OK; } -/* ---- 驱动:整批载入到尾部/上限,返回最终 song_count ---- */ +/* ---- 驱动:重头 + 逐段 ensure 到尾部,返回 track_ids_count ---- */ static int load_all(int pid_total, int *detail_calls, int *song_calls) { memset(&g_st, 0, sizeof g_st); @@ -87,17 +91,13 @@ static int load_all(int pid_total, int *detail_calls, int *song_calls) 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) { + for (int off = NCM_SONG_BATCH; off < g_st.track_ids_count; off += NCM_SONG_BATCH) { 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; + return g_st.track_ids_count; } static int g_fail = 0; @@ -108,36 +108,50 @@ static int g_fail = 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"); + printf("ncm_song_t=%zuB window=%zuKB(%d首) trackIds idx=%zuKB(%d首)\n", + sizeof(ncm_song_t), + sizeof g_st.songs / 1024, NCM_SONG_WINDOW, + sizeof g_st.song_ids / 1024, NCM_TRACKID_MAX); + + /* (a) 130 首(< 窗口):读全程后 130 槽全有效(窗口装得下,无需滑窗)。 */ + printf("== case A: 130 songs (whole list fits window) ==\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(n == 130, "track_ids_count=%d (expect 130)", n); + CHECK(g_st.song_count == 130, "valid slots=%d (expect 130, all in window)", g_st.song_count); + CHECK(g_st.window_base == 0, "window_base=%d (expect 0)", g_st.window_base); 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); + CHECK(sc == 2, "song_detail calls=%d (expect 2 = batches 0-99 + 100-129)", sc); + ncm_song_t sq; + CHECK(appstate_song_copy(129, &sq) == 0 && strcmp(sq.name, "Song1000129") == 0, + "last song copy name='%s'", sq.name); + CHECK(sq.al_pic[0] != 0, "last song has al_pic='%s'", sq.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); + CHECK(rc == NCM_OK && g_st.song_count == 130, "load past tail: rc=%d valid=%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"); + /* (b) 5191 首(>> 窗口):索引全量不截断(§17 取消 NCM_SONG_MAX=1000 上限), + * 窗口有效槽恒 ≤ NCM_SONG_WINDOW,末段滑窗后末首可达。 */ + printf("== case B: 5191 songs (no cap; window slides) ==\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(n == 5191, "track_ids_count=%d (expect 5191, 不再 1000 截断)", n); + CHECK(g_st.song_count <= NCM_SONG_WINDOW, "valid slots=%d ≤ window %d", g_st.song_count, NCM_SONG_WINDOW); 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); + CHECK(sc == (5191 + NCM_SONG_BATCH - 1) / NCM_SONG_BATCH, + "song_detail calls=%d (expect %d grid batches)", sc, (5191 + NCM_SONG_BATCH - 1) / NCM_SONG_BATCH); + CHECK(appstate_song_copy(5190, &sq) == 0 && strcmp(sq.name, "Song1005190") == 0, + "tail song copy name='%s'", sq.name); + CHECK(appstate_song_copy(0, &sq) != 0, "head song evicted (out of window) -> copy fails"); + /* 越界再 load:应 NCM_OK 但不动窗口 */ + int valid_before = g_st.song_count; + rc = appstate_load_playlist_songs_page(0, 5191, NCM_SONG_BATCH); + CHECK(rc == NCM_OK && g_st.song_count == valid_before, "load past tail: rc=%d valid=%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); + CHECK(n == 0 && g_st.song_count == 0, "empty: track_ids_count=%d valid=%d", n, g_st.song_count); printf("%s\n", g_fail ? "RESULT: FAIL" : "RESULT: PASS"); return g_fail ? 1 : 0; diff --git a/patch_v2/ncm_app/bench/window_sim.c b/patch_v2/ncm_app/bench/window_sim.c new file mode 100644 index 0000000..dc42eb3 --- /dev/null +++ b/patch_v2/ncm_app/bench/window_sim.c @@ -0,0 +1,231 @@ +/* bench/window_sim.c — 三期第三批 滑动窗口 6000 首滚动全程离线验证(0 网络请求)。 + * + * 驱动真实 appstate.c(滑动窗口)+ 真实 songmeta.c(盘缓存,/tmp 临时目录), + * 网络端点(ncm_api_playlist_detail / ncm_api_song_detail)为本地合成 stub。 + * 场景(对齐用户「我喜欢」6000+ 首): + * ① 重头:detail 取全量 6000 trackIds,物化首段(1 detail + 1 song_detail 批); + * ② 全程下滚:视口 12 行、步进 8 行(模拟跟手拖动),每步 pl_maybe_ensure 等价物 + * ensure(pos-24, pos+36);每步断言视口 12 行全部物化(列表无占位破窗); + * 断言 song_detail 调用数 ≈ 6000/批网格(批对齐预取,无小请求放大); + * ③ 全程回滚到 0:盘缓存(songmeta)全命中,song_detail 调用数 0 增长; + * ④ 播放兼容:窗口感知的取歌——播放线程路径 appstate_song_fetch 在窗口远离时 + * 仍能物化当前播放曲(不滑窗、不崩、盘缓存命中免请求); + * ⑤ 尾部/越界:ensure 尾部区间 + 越过尾部无操作; + * ⑥ 内存账:6000 首场景改前(1000 截断 × 1324B)vs 改后(窗口 256×608B + 索引)。 + * 风控纪律:0 次真实 NCM 请求(离线 stub + /tmp 盘缓存)。 + * + * 编译(宿主):$CC_HOST -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ + * -Icore -I../vendor/jsmn -c bench/window_sim.c -o /tmp/ws.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/ws_app.o + * $CC_HOST -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ + * -Icore -I../vendor/jsmn -c app/logic/songmeta.c -o /tmp/ws_meta.o + * $CC_HOST -O2 -Wl,--gc-sections -o window_sim /tmp/ws.o /tmp/ws_app.o /tmp/ws_meta.o -lpthread + * 运行:./window_sim (0 网络;PASS/FAIL 退出码) + */ +#include "../app/ncm_app_internal.h" +#include +#include +#include + +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; + +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(离线,合成数据) ---- */ +#define SIM_TOTAL 6000 +static int g_detail_calls; +static int g_song_calls; +static int g_song_served; /* song_detail 累计返回首数 */ + +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; (void)playlist_id; + g_detail_calls++; + if (name) snprintf(name, name_sz, "Sim Playlist"); + if (cover) cover[0] = 0; + int n = (SIM_TOTAL < id_cap) ? SIM_TOTAL : id_cap; + for (int i = 0; i < n; i++) ids[i] = 2000000LL + i; + *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; + g_song_served += m; + 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].album_name, sizeof out[i].album_name, "Album%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[i].duration_ms = 200000; + } + *out_n = m; + return NCM_OK; +} + +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) + +/* 模拟 playlist.c pl_maybe_ensure:视口 12 行 ±24 行余量。 */ +static int ui_ensure(int pos) +{ + int lo = pos - 24, hi = pos + 12 + 24; + if (lo < 0) lo = 0; + if (hi > SIM_TOTAL) hi = SIM_TOTAL; + if (lo >= hi) return NCM_OK; + if (appstate_range_loaded(lo, hi)) return NCM_OK; + return appstate_song_ensure_range(lo, hi); +} + +int main(void) +{ + if (system("rm -rf /tmp/ncm_window_sim_cache") != 0) { /* 清上次运行残留(盘缓存) */ + printf("FAIL cannot clean /tmp cache dir\n"); + return 1; + } + memset(&g_st, 0, sizeof g_st); + snprintf(g_st.cache_dir, sizeof g_st.cache_dir, "/tmp/ncm_window_sim_cache"); + g_st.playlist_count = 1; + snprintf(g_st.playlists[0].id, sizeof g_st.playlists[0].id, "pl6000"); + g_st.playlists[0].track_count = SIM_TOTAL; + + size_t win_bytes = sizeof g_st.songs + sizeof g_st.song_valid; + size_t idx_bytes = sizeof g_st.song_ids; + size_t old_bytes = (size_t)1000 * 1324; /* §14:NCM_SONG_MAX=1000 × ~1324B/首 */ + printf("== memory model ==\n"); + printf(" ncm_song_t=%zuB (old 1324B) window=%zuB(%d首) trackIds idx=%zuB(%d首)\n", + sizeof(ncm_song_t), win_bytes, NCM_SONG_WINDOW, idx_bytes, NCM_TRACKID_MAX); + printf(" 6000首场景:旧模型 %zuKB(且 1000 截断)→ 新模型 %zuKB(任意长度恒定)\n", + old_bytes / 1024, (win_bytes + idx_bytes) / 1024); + + /* ① 重头 */ + printf("== phase 1: open 6000-song playlist ==\n"); + int rc = appstate_load_playlist_songs_page(0, 0, NCM_SONG_BATCH); + CHECK(rc == NCM_OK, "page0 rc=%d", rc); + CHECK(g_st.track_ids_count == SIM_TOTAL, "track_ids_count=%d (expect %d, 无截断)", + g_st.track_ids_count, SIM_TOTAL); + CHECK(g_st.song_count == NCM_SONG_BATCH, "valid slots=%d (expect first batch %d)", + g_st.song_count, NCM_SONG_BATCH); + CHECK(g_st.window_base == 0, "window_base=%d", g_st.window_base); + CHECK(g_detail_calls == 1 && g_song_calls == 1, "detail=%d song_detail=%d (expect 1+1)", + g_detail_calls, g_song_calls); + + /* ② 全程下滚(0→6000,步进 8 行):视口每步全部物化 */ + printf("== phase 2: scroll down 0 -> %d (step 8 rows) ==\n", SIM_TOTAL); + int step_checks = 0; + for (int pos = 0; pos < SIM_TOTAL; pos += 8) { + rc = ui_ensure(pos); + if (rc != NCM_OK) { printf(" FAIL ensure(pos=%d) rc=%d\n", pos, rc); g_fail = 1; break; } + int vhi = pos + 12; if (vhi > SIM_TOTAL) vhi = SIM_TOTAL; + if (!appstate_range_loaded(pos, vhi)) { + printf(" FAIL viewport [%d,%d) not fully materialized\n", pos, vhi); + g_fail = 1; break; + } + step_checks++; + } + CHECK(step_checks == (SIM_TOTAL + 7) / 8, "scroll steps verified=%d (expect %d)", + step_checks, (SIM_TOTAL + 7) / 8); + CHECK(g_st.song_count <= NCM_SONG_WINDOW, "valid slots=%d ≤ window %d (全程不超窗)", + g_st.song_count, NCM_SONG_WINDOW); + printf(" INFO song_detail calls=%d served=%d (grid prefetch; 下限 %d)\n", + g_song_calls, g_song_served, SIM_TOTAL / NCM_SONG_BATCH); + CHECK(g_song_calls <= SIM_TOTAL / NCM_SONG_BATCH + 6, + "song_detail calls=%d ≤ %d (批网格对齐,无小请求放大)", + g_song_calls, SIM_TOTAL / NCM_SONG_BATCH + 6); + ncm_song_t sq; + CHECK(appstate_song_copy(SIM_TOTAL - 1, &sq) == 0 && + strcmp(sq.name, "Song2005999") == 0, "tail song name='%s'", sq.name); + CHECK(appstate_song_copy(0, &sq) != 0, "head evicted after scrolling to tail"); + + /* ③ 全程回滚到 0:盘缓存全命中,0 新增 song_detail */ + printf("== phase 3: scroll back up %d -> 0 (disk cache) ==\n", SIM_TOTAL); + int calls_before = g_song_calls; + step_checks = 0; + for (int pos = SIM_TOTAL - 8; pos >= 0; pos -= 8) { + rc = ui_ensure(pos); + if (rc != NCM_OK) { printf(" FAIL ensure(pos=%d) rc=%d\n", pos, rc); g_fail = 1; break; } + int vhi = pos + 12; if (vhi > SIM_TOTAL) vhi = SIM_TOTAL; + if (!appstate_range_loaded(pos, vhi)) { + printf(" FAIL viewport [%d,%d) not materialized on way back\n", pos, vhi); + g_fail = 1; break; + } + step_checks++; + } + CHECK(g_song_calls == calls_before, + "scroll-back song_detail calls +%d (expect 0,盘缓存全命中)", g_song_calls - calls_before); + CHECK(appstate_song_copy(0, &sq) == 0 && strcmp(sq.name, "Song2000000") == 0, + "head re-materialized from disk: name='%s'", sq.name); + + /* ④ 播放兼容:窗口滑出当前播放曲 → fetch 不滑窗、不崩、盘命中免请求 */ + printf("== phase 4: playback survives window slide ==\n"); + rc = appstate_song_ensure_range(3000 - 128, 3000 + 128); /* 窗口移到 3000 附近 */ + int base_at_play = g_st.window_base; + CHECK(rc == NCM_OK, "ensure around 3000 rc=%d", rc); + /* 模拟 run_song:cur=3050 在窗内 → song_copy;然后窗口滑回 0,再 fetch(3050) */ + CHECK(appstate_song_copy(3050, &sq) == 0, "song_copy(3050) in window"); + rc = appstate_song_ensure_range(0, 100); /* 用户滚回顶部(窗口滑走) */ + CHECK(rc == NCM_OK && g_st.window_base != base_at_play, + "window slid away base %d -> %d", base_at_play, g_st.window_base); + CHECK(appstate_song_copy(3050, &sq) != 0, "playing song evicted from window"); + calls_before = g_song_calls; + rc = appstate_song_fetch(3050, &sq); /* 播放线程路径:不滑窗 */ + CHECK(rc == NCM_OK && strcmp(sq.id, "2003050") == 0 && sq.duration_ms == 200000, + "fetch(3050) ok id=%s dur=%d", sq.id, sq.duration_ms); + CHECK(g_st.window_base != base_at_play && g_st.song_count <= NCM_SONG_WINDOW, + "fetch did not move window (base=%d)", g_st.window_base); + CHECK(g_song_calls == calls_before, "fetch hit disk cache (0 new requests)"); + /* next/prev 边界语义:cur 可到 SIM_TOTAL-1,不越界不崩 */ + CHECK(appstate_song_fetch(SIM_TOTAL - 1, &sq) == NCM_OK, "fetch(tail) ok"); + CHECK(appstate_song_fetch(SIM_TOTAL, &sq) != NCM_OK, "fetch(out of range) rejected"); + + /* ⑤ 尾部/越界 ensure */ + printf("== phase 5: tail & out-of-range ==\n"); + rc = appstate_song_ensure_range(SIM_TOTAL - 12, SIM_TOTAL); + CHECK(rc == NCM_OK && appstate_range_loaded(SIM_TOTAL - 12, SIM_TOTAL), + "tail ensure rc=%d", rc); + CHECK(g_st.window_base <= SIM_TOTAL - NCM_SONG_WINDOW || + SIM_TOTAL < NCM_SONG_WINDOW, "base=%d clamped at tail", g_st.window_base); + rc = appstate_song_ensure_range(SIM_TOTAL, SIM_TOTAL + 100); + CHECK(rc == NCM_OK, "ensure past tail no-op rc=%d", rc); + rc = appstate_song_ensure_range(-50, 0); + CHECK(rc == NCM_OK, "ensure empty range no-op rc=%d", rc); + + /* ⑥ 盘缓存规模核对 */ + printf("== phase 6: disk cache ==\n"); + { + char cmd[256]; + snprintf(cmd, sizeof cmd, + "ls /tmp/ncm_window_sim_cache/meta | wc -l"); + FILE *p = popen(cmd, "r"); + int files = -1; + if (p) { if (fscanf(p, "%d", &files) != 1) files = -1; pclose(p); } + CHECK(files == SIM_TOTAL, "songmeta files=%d (expect %d, 全程拉过即落盘)", files, SIM_TOTAL); + } + + printf("%s\n", g_fail ? "RESULT: FAIL" : "RESULT: PASS"); + return g_fail ? 1 : 0; +} diff --git a/patch_v2/ncm_app/build_ncm.sh b/patch_v2/ncm_app/build_ncm.sh index ecc9c67..d7f566c 100755 --- a/patch_v2/ncm_app/build_ncm.sh +++ b/patch_v2/ncm_app/build_ncm.sh @@ -19,7 +19,7 @@ CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \ core/ncm_cache.c core/ncm_ctx.c" APP="app/app.c app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \ app/ui/settings.c app/ui/search.c app/ui/ncm_colors.c app/ui/chrome.c \ - app/logic/appstate.c app/logic/lyric.c \ + app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \ app/logic/cover.c app/play/player_manager.c app/play/audio_out.c \ app/play/ipc_media.c app/play/minimp3_impl.c" diff --git a/patch_v2/ncm_app/build_ncm_host.sh b/patch_v2/ncm_app/build_ncm_host.sh index b18238f..96e8693 100755 --- a/patch_v2/ncm_app/build_ncm_host.sh +++ b/patch_v2/ncm_app/build_ncm_host.sh @@ -31,7 +31,7 @@ $CC_HOST -O2 -c -I"$SDK_DIR" -I"$ROOT/patch_v2/meow_launcherd" "$SDK_DIR/test_ho echo "==> host app sources (exclude app.c: has main + g_st; test provides them)" APP_SRC="app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \ app/ui/settings.c app/ui/search.c app/ui/ncm_colors.c app/ui/chrome.c \ - app/logic/appstate.c app/logic/lyric.c \ + app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \ app/logic/cover.c app/play/player_manager.c app/play/audio_out.c \ app/play/ipc_media.c app/play/minimp3_impl.c" for f in $APP_SRC; do diff --git a/patch_v2/ncm_app/core/ncm_api.h b/patch_v2/ncm_app/core/ncm_api.h index 6a86539..ac91569 100644 --- a/patch_v2/ncm_app/core/ncm_api.h +++ b/patch_v2/ncm_app/core/ncm_api.h @@ -33,13 +33,18 @@ typedef struct ncm_playlist { int is_collected; /* 1=我收藏的(订阅他人);0=我创建的。来自响应 subscribed(§7.0) */ } ncm_playlist_t; +/* 瘦身(REPORT §17):旧版 name/art/al 各 256 + al_pic 512 + long dt ≈ 1324B/首, + * 滑动窗口常驻 256 首 ≈331KB 太肥;裁到 ≈608B/首(窗口 ≈152KB)。id 为数字文本 + * (int64 最大 19 位 + NUL,24 足够);name 128 / art 96 / al 96(列表/播放页展示均按宽 + * 再截断);al_pic 256(picUrl 实际 <200 字符,obj_str 截断安全);dt 用 int32_t + * (时长 <2^31ms≈24 天;MIPS32 的 long 本就 32 位)。字段变窄只影响展示截断,不改变语义。 */ typedef struct ncm_song { - char id[32]; - char name[256]; - char art_name[256]; /* 首个歌手名 */ - char album_name[256]; - char al_pic[512]; /* 专辑封面 picUrl(song_detail/cloudsearch/playlist_track_all 解析;播放页黑胶封面) */ - long duration_ms; + char id[24]; + char name[128]; + char art_name[96]; /* 首个歌手名 */ + char album_name[96]; + char al_pic[256]; /* 专辑封面 picUrl(song_detail/cloudsearch 解析;播放页黑胶封面) */ + int32_t duration_ms; int fee; } ncm_song_t; diff --git a/patch_v2/ncm_app/ncm b/patch_v2/ncm_app/ncm index 25591d7..0ea3a61 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 1fba9e4..a25f240 100755 Binary files a/patch_v2/ncm_app/ncm_host and b/patch_v2/ncm_app/ncm_host differ diff --git a/patch_v2/ncm_app/test/test_ncm_core_host b/patch_v2/ncm_app/test/test_ncm_core_host index fd23257..89e0b61 100755 Binary files a/patch_v2/ncm_app/test/test_ncm_core_host and b/patch_v2/ncm_app/test/test_ncm_core_host differ