From f392ee5c48b7aa9b9c7ee93440ee72ac44ed2be2 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 20:34:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(ncm):=20=E9=A6=96=E9=A1=B5=E9=A1=B6?= =?UTF-8?q?=E6=A0=8F=E3=80=8C=E5=B7=B2=E7=BC=93=E5=AD=98=E3=80=8D=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=20+=20=E5=B7=B2=E7=BC=93=E5=AD=98=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E9=A1=B5=EF=BC=88=E6=AD=8C=E5=90=8D/=E6=AD=8C=E6=89=8B/?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E6=A1=A3=E4=BD=8D/=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=EF=BC=8C=E7=82=B9=E6=92=AD/=E5=8D=95=E6=9B=B2=E5=88=A0?= =?UTF-8?q?=E9=99=A4/=E6=B8=85=E7=A9=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 入口:首页 topbar 新增动作 3(MEOW_ICON_DOWNLOAD 位图,原「回播放页」顺延动作 4)→ push ACT_CACHE;左缘返回弹回首页(app.c 路由补齐 tap/swipe/touch/tick)。 列表页 app/ui/cache.c(对齐 queue.c 惯例:圆角卡/红 mini-vinyl/按压红条/当前播放行高亮): - 行 = 歌名 / 歌手 · 实际档位 · 大小;空态/页脚(N 首 · 合计 X MB)。 - 点行 = 点播(NCM_QUEUE_CACHE 队列,上/下一首在缓存列表内循环);行右「删除」=单曲删除; 顶栏「清空」=确认框 + ncm_cache_clear(与设置页「清理缓存」同语义)。 - 数据全在 worker:cache_reload → appstate_cache_list_load → ncm_cache_scan(读目录 + 文件内 tag + 音频头探测)→ 快照;UI 线程零磁盘 IO。快照上限 512 条(最近访问优先, ≈116KB bss),更多条目按 mtime 保留最近的。 core: - ncm_cache_scan / ncm_cache_remove(songs/ 池,song__<请求档>.<实际格式> 解析, 跳过 .tmp/.part 与非数字 id;按 mtime 降序;历史双点名删除兜底)。 - ncm_api_level_from_key(缓存键后缀 ↔ 档位枚举,与 ncm_api_level_key 互逆)。 - **档位显示实际档而非请求档**:真机存在 song_171650_hires.mp3(无损请求被降级链落到 exhigh mp3),故 scan 用 ncm_audio_probe_file 读实际容器参数后只降不升地推断 (FLAC 16bit→无损 / 24bit→Hi-Res+,MP3 ≤128k→标准 / >128k→极高)。 播放:player_manager 对 NCM_QUEUE_CACHE 用条目真实键(id+请求档+实际扩展名)直读缓存 文件,不按用户当前档键重下;song_local_file/play_is_same_song/queue 页均支持第三种队列。 宿主 mock(NCM_PAGE=cache)造 3 条缓存 + 嵌入 tag + 同步扫描。 --- patch_v2/ncm_app/app/app.c | 10 +- patch_v2/ncm_app/app/logic/appstate.c | 101 +++++ patch_v2/ncm_app/app/ncm_app_internal.h | 34 ++ patch_v2/ncm_app/app/play/ipc_media.c | 1 + patch_v2/ncm_app/app/play/player_manager.c | 32 +- patch_v2/ncm_app/app/test/test_ncm_app.c | 38 +- patch_v2/ncm_app/app/ui/cache.c | 412 +++++++++++++++++++++ patch_v2/ncm_app/app/ui/home.c | 17 +- patch_v2/ncm_app/app/ui/player.c | 11 + patch_v2/ncm_app/app/ui/queue.c | 11 +- patch_v2/ncm_app/build_ncm.sh | 2 +- patch_v2/ncm_app/build_ncm_host.sh | 3 +- patch_v2/ncm_app/core/ncm_api.c | 16 + patch_v2/ncm_app/core/ncm_api.h | 2 + patch_v2/ncm_app/core/ncm_cache.c | 133 +++++++ patch_v2/ncm_app/core/ncm_cache.h | 24 ++ 16 files changed, 829 insertions(+), 18 deletions(-) create mode 100644 patch_v2/ncm_app/app/ui/cache.c diff --git a/patch_v2/ncm_app/app/app.c b/patch_v2/ncm_app/app/app.c index 573f81c..9c9cc47 100644 --- a/patch_v2/ncm_app/app/app.c +++ b/patch_v2/ncm_app/app/app.c @@ -24,7 +24,7 @@ volatile int g_net_ready = 0; int g_host_mock = 0; static meow_view *g_login_view, *g_home_view, *g_pl_view, *g_plr_view, *g_set_view, - *g_search_view, *g_queue_view; + *g_search_view, *g_queue_view, *g_cache_view; static void on_sigterm(int s) { (void)s; meow_ui_request_exit(); } @@ -93,6 +93,7 @@ static void app_on_tap(int x, int y) if (settings_is_active()) { settings_on_tap(x, y); return; } if (playlist_is_active()) { playlist_on_tap(x, y); return; } if (queue_is_active()) { queue_on_tap(x, y); return; } + if (cache_is_active()) { cache_on_tap(x, y); return; } if (search_is_active()) { search_on_tap(x, y); return; } home_on_tap(x, y); } @@ -107,6 +108,7 @@ static void app_on_swipe(int dir, int dx, int dy) if (settings_is_active()) { meow_view_page_pop(); return; } if (playlist_is_active()) { meow_view_page_pop(); return; } if (queue_is_active()) { meow_view_page_pop(); return; } + if (cache_is_active()) { meow_view_page_pop(); return; } if (search_is_active()) { meow_view_page_pop(); return; } if (home_is_active()) { /* 任务2:home 根页左滑不再退出进程 → 请求 launcher 退到桌面/后台(进程+媒体会话保留)。 @@ -124,6 +126,7 @@ static void app_on_swipe(int dir, int dx, int dy) if (home_is_active()) { home_swipe(dir, dy); return; } if (playlist_is_active()) { playlist_swipe(dir, dy); return; } if (queue_is_active()) { queue_swipe(dir, dy); return; } + if (cache_is_active()) { cache_swipe(dir, dy); return; } if (search_is_active()) { search_swipe(dir, dy); return; } if (player_is_active()) { player_on_swipe(dir, dy); return; } } @@ -134,6 +137,7 @@ static void app_on_touch_down(int x, int y) else if (home_is_active()) home_touch_down(x, y); else if (playlist_is_active()) playlist_touch_down(x, y); else if (queue_is_active()) queue_touch_down(x, y); + else if (cache_is_active()) cache_touch_down(x, y); else if (settings_is_active()) settings_touch_down(x, y); else if (search_is_active()) search_touch_down(x, y); } @@ -144,6 +148,7 @@ static void app_on_touch_move(int x, int y) else if (home_is_active()) home_touch_move(x, y); else if (playlist_is_active()) playlist_touch_move(x, y); else if (queue_is_active()) queue_touch_move(x, y); + else if (cache_is_active()) cache_touch_move(x, y); else if (settings_is_active()) settings_touch_move(x, y); else if (search_is_active()) search_touch_move(x, y); } @@ -194,6 +199,7 @@ static void app_on_tick(void) if (player_is_active()) { player_tick(now); return; } if (search_is_active()) { search_tick(now); return; } if (queue_is_active()) { queue_tick(now); return; } + if (cache_is_active()) { cache_tick(now); return; } if (home_is_active()) { home_tick(now); } if (playlist_is_active()) { playlist_tick(now); } chrome_tick(); @@ -233,6 +239,7 @@ int main(void) g_set_view = settings_view_create(); g_search_view = search_view_create(); g_queue_view = queue_view_create(); /* §NEW 当前播放队列页 */ + g_cache_view = cache_view_create(); /* M5 已缓存列表页 */ mini_bar_init(); /* mini 播放栏:登记跨 Activity 绘制物(进 root 页前,首帧即参与重画) */ /* 进入根页:已登录→home,否则→login */ @@ -256,6 +263,7 @@ int main(void) meow_view_free(g_login_view); meow_view_free(g_search_view); meow_view_free(g_queue_view); + meow_view_free(g_cache_view); meow_ui_shutdown(); meow_log(MEOW_LOG_INFO, "exit"); return 0; diff --git a/patch_v2/ncm_app/app/logic/appstate.c b/patch_v2/ncm_app/app/logic/appstate.c index d263710..c0652b2 100644 --- a/patch_v2/ncm_app/app/logic/appstate.c +++ b/patch_v2/ncm_app/app/logic/appstate.c @@ -783,6 +783,7 @@ void appstate_apply_play_mode(int m) int appstate_queue_count(void) { int c; + if (g_st.queue_src == NCM_QUEUE_CACHE) return appstate_cache_count(); pthread_mutex_lock(&g_search_mu); c = (g_st.queue_src == NCM_QUEUE_SEARCH) ? g_st.search_song_count : g_st.track_ids_count; pthread_mutex_unlock(&g_search_mu); @@ -792,6 +793,7 @@ int appstate_queue_count(void) int appstate_queue_song(int idx, ncm_song_t *out) { if (!out) return NCM_ERR_ARG; + if (g_st.queue_src == NCM_QUEUE_CACHE) return appstate_cache_song(idx, out); if (g_st.queue_src == NCM_QUEUE_SEARCH) { pthread_mutex_lock(&g_search_mu); int ok = (idx >= 0 && idx < g_st.search_song_count); @@ -803,6 +805,105 @@ int appstate_queue_song(int idx, ncm_song_t *out) return appstate_song_fetch(idx, out); } +/* ============================================================ + * 已缓存列表(ACT_CACHE / NCM_QUEUE_CACHE;数据源 = songs/ 池 + 文件内 tag) + * 数组读写经 g_cache_mu;扫描走 worker(磁盘 IO 不进 UI 线程)。 + * ============================================================ */ +static pthread_mutex_t g_cache_mu = PTHREAD_MUTEX_INITIALIZER; + +int appstate_cache_list_load(void) +{ + /* 堆上临时数组(≈116KB):扫描期间不持锁,UI/播放线程读旧快照不被 IO 阻塞。 */ + ncm_cache_item_t *tmp = (ncm_cache_item_t *)malloc(sizeof(ncm_cache_item_t) * NCM_CACHE_LIST_MAX); + if (!tmp) return NCM_ERR_OOM; + int n = ncm_cache_scan(&g_st.ctx.cache, tmp, NCM_CACHE_LIST_MAX); + if (n < 0) { free(tmp); return n; } + /* 旧无 tag 缓存:回退旁路元数据(songmeta)补名称/歌手,保持列表可读。 */ + for (int i = 0; i < n; i++) { + ncm_song_t s; + if (tmp[i].name[0] && tmp[i].artist[0]) continue; + if (songmeta_load(g_st.cache_dir, tmp[i].id, &s) != NCM_OK) continue; + if (!tmp[i].name[0]) ncm_strlcpy(tmp[i].name, s.name, sizeof tmp[i].name); + if (!tmp[i].artist[0]) ncm_strlcpy(tmp[i].artist, s.art_name, sizeof tmp[i].artist); + } + pthread_mutex_lock(&g_cache_mu); + memcpy(g_st.cache_items, tmp, sizeof(ncm_cache_item_t) * (size_t)n); + g_st.cache_item_count = n; + pthread_mutex_unlock(&g_cache_mu); + free(tmp); + return n; +} + +int appstate_cache_count(void) +{ + int n; + pthread_mutex_lock(&g_cache_mu); + n = g_st.cache_item_count; + pthread_mutex_unlock(&g_cache_mu); + return n < 0 ? 0 : n; +} + +int appstate_cache_item(int i, ncm_cache_item_t *out) +{ + if (!out) return NCM_ERR_ARG; + pthread_mutex_lock(&g_cache_mu); + int ok = (i >= 0 && i < g_st.cache_item_count); + if (ok) *out = g_st.cache_items[i]; + pthread_mutex_unlock(&g_cache_mu); + return ok ? NCM_OK : NCM_ERR_ARG; +} + +int appstate_cache_song(int i, ncm_song_t *out) +{ + ncm_cache_item_t it; + ncm_song_t m; + if (!out) return NCM_ERR_ARG; + if (appstate_cache_item(i, &it) != NCM_OK) return NCM_ERR_ARG; + memset(out, 0, sizeof *out); + ncm_strlcpy(out->id, it.id, sizeof out->id); + ncm_strlcpy(out->name, it.name[0] ? it.name : it.id, sizeof out->name); + ncm_strlcpy(out->art_name, it.artist, sizeof out->art_name); + /* 旁路元数据(≤600B 单次读)补时长/封面/fee;播放页与上/下一首逻辑照旧可用。 */ + if (songmeta_load(g_st.cache_dir, it.id, &m) == NCM_OK) { + if (!it.name[0] && m.name[0]) ncm_strlcpy(out->name, m.name, sizeof out->name); + if (!it.artist[0] && m.art_name[0]) ncm_strlcpy(out->art_name, m.art_name, sizeof out->art_name); + out->duration_ms = m.duration_ms; + out->fee = m.fee; + ncm_strlcpy(out->al_pic, m.al_pic, sizeof out->al_pic); + } + return NCM_OK; +} + +int appstate_cache_remove(int i) +{ + ncm_cache_item_t it; + int rc; + if (appstate_cache_item(i, &it) != NCM_OK) return NCM_ERR_ARG; + /* 条目可能已被 LRU/清空删掉:NOENT 也算「已不在」,照样摘除列表项。 */ + rc = ncm_cache_remove(&g_st.ctx.cache, it.id, it.level, it.ext); + if (rc != NCM_OK && rc != NCM_ERR_NOENT) return rc; + pthread_mutex_lock(&g_cache_mu); + for (int k = 0; k < g_st.cache_item_count; k++) { + if (strcmp(g_st.cache_items[k].id, it.id) != 0 || + strcmp(g_st.cache_items[k].level, it.level) != 0 || + strcmp(g_st.cache_items[k].ext, it.ext) != 0) continue; + memmove(&g_st.cache_items[k], &g_st.cache_items[k + 1], + sizeof(ncm_cache_item_t) * (size_t)(g_st.cache_item_count - k - 1)); + g_st.cache_item_count--; + break; + } + pthread_mutex_unlock(&g_cache_mu); + return NCM_OK; +} + +void appstate_cache_clear(void) +{ + ncm_cache_clear(&g_st.ctx.cache); /* songs/ + covers/ 的 song_*(设置页同语义) */ + pthread_mutex_lock(&g_cache_mu); + g_st.cache_item_count = 0; + pthread_mutex_unlock(&g_cache_mu); +} + int appstate_search_count(int tab) { int c; diff --git a/patch_v2/ncm_app/app/ncm_app_internal.h b/patch_v2/ncm_app/app/ncm_app_internal.h index 43e5bb2..c8fb8f2 100644 --- a/patch_v2/ncm_app/app/ncm_app_internal.h +++ b/patch_v2/ncm_app/app/ncm_app_internal.h @@ -31,6 +31,7 @@ #define ACT_SETTINGS 4 /* 设置页 */ #define ACT_SEARCH 5 /* 搜索页(顶栏搜索按钮进入) */ #define ACT_QUEUE 6 /* 当前播放队列页(播放页「队列」钮进入,展示实时播放列表) */ +#define ACT_CACHE 7 /* 已缓存列表页(首页顶栏「已缓存」入口,NCM_CACHE_DESIGN §2.5) */ /* ---- 播放模式三态(REPORT §NEW:随机 / 列表循环 / 单曲循环)---- * 默认=列表循环(延续既有「顺序下一首」语义;列表循环下自动顺延会在队尾回绕)。 @@ -115,6 +116,11 @@ static inline int ncm_row_card_h(int item) { return item - NCM_ROW_GAP; } /* 播放队列来源(§23:搜索单曲起播建立「搜索队列」上下文,不覆写歌单窗口) */ #define NCM_QUEUE_PLAYLIST 0 /* 默认:歌单滑动窗口 */ #define NCM_QUEUE_SEARCH 1 /* 搜索单曲结果队列 */ +#define NCM_QUEUE_CACHE 2 /* 已缓存列表队列(点播缓存页条目;上/下一首在缓存列表内循环) */ + +/* 已缓存列表条目上限(NCM_CACHE_LIST_MAX 条 × sizeof(ncm_cache_item_t)≈232B ≈ 116KB bss)。 + * 缓存池条目更多时只展示最近访问的 max 条(scan 按 mtime 降序保留最近的)。 */ +#define NCM_CACHE_LIST_MAX 512 /* active_playlist 哨兵:搜索歌单详情(复用 playlist_open 链路,读 g_st.search_pl_open) */ #define NCM_PL_SEARCH (-2) @@ -269,6 +275,10 @@ typedef struct ncm_app_state { /* 播放队列来源(§23):默认 NCM_QUEUE_PLAYLIST;搜索单曲起播切 NCM_QUEUE_SEARCH */ int queue_src; + + /* 已缓存列表(ACT_CACHE 页 + NCM_QUEUE_CACHE 队列;worker 扫描磁盘填充,UI 只读) */ + ncm_cache_item_t cache_items[NCM_CACHE_LIST_MAX]; + int cache_item_count; } ncm_app_state_t; extern ncm_app_state_t g_st; /* 全局状态(app.c 定义) */ @@ -353,6 +363,7 @@ void player_tick(uint64_t now); /* 播放进度/歌词滚动 */ void player_play_song(int idx); /* 从歌单窗口第 idx 首开始播放(queue=PLAYLIST) */ void player_play_search_song(int i); /* 从搜索单曲第 i 首开始播放(queue=SEARCH,§23) */ void player_play_queue_song(int idx);/* 从「当前实时播放队列」第 idx 首播放(保持当前 queue_src,§NEW) */ +void player_play_cache_song(int i); /* 从已缓存列表第 i 首点播(切 NCM_QUEUE_CACHE 队列) */ void player_pause_toggle(int on); void player_next(int dir); /* dir=+1 下一首 / -1 上一首 */ void player_seek(int64_t ms); @@ -390,6 +401,19 @@ void queue_touch_move(int x, int y); void queue_open(void); /* 进当前播放队列页 */ void queue_tick(uint64_t now); /* UI tick:检查队列 worker 完成并刷新 */ +/* cache(已缓存列表页,ACT_CACHE:首页顶栏「已缓存」钮进入,NCM_CACHE_DESIGN §2.5) + * 列表 = songs/ 池扫描(worker 读盘,UI 零 IO);行显示 歌名/歌手/实际档位/大小; + * 点行=点播(NCM_QUEUE_CACHE),行右「删除」=单曲删除,顶栏「清空」=清空缓存池。 */ +meow_view *cache_view_create(void); +int cache_is_active(void); +int cache_on_tap(int x, int y); +void cache_swipe(int dir, int dist); +void cache_touch_down(int x, int y); +void cache_touch_move(int x, int y); +void cache_open(void); /* 进已缓存列表页(push ACT_CACHE) */ +void cache_reload(void); /* 起 worker 重扫缓存池(非阻塞) */ +void cache_tick(uint64_t now); /* UI tick:检查扫描 worker 完成并刷新 */ + /* search */ meow_view *search_view_create(void); int search_is_active(void); @@ -470,6 +494,16 @@ int appstate_queue_count(void); /* 当前播放队列长度( int appstate_queue_song(int idx, ncm_song_t *out); /* 队列第 idx 首;0=ok(按 queue_src) */ void appstate_apply_play_mode(int m); /* 设 g_st.play_mode + 落盘(持久化「记住模式」,§NEW) */ +/* ---- 已缓存列表(NCM_CACHE_LIST_MAX;磁盘扫描/删除只在 worker 或 UI 单次小 IO)---- + * queue_src==NCM_QUEUE_CACHE 时,appstate_queue_count/song 走本数组(播放页上/下一首同源)。 + * 名称/歌手优先取文件内 tag(与外部播放器同源),空则回退旁路元数据 /meta/.bin。 */ +int appstate_cache_list_load(void); /* worker:ncm_cache_scan + 旁路补名;返回条数或负错误码 */ +int appstate_cache_count(void); +int appstate_cache_item(int i, ncm_cache_item_t *out); /* 0=ok */ +int appstate_cache_song(int i, ncm_song_t *out); /* 合成 ncm_song_t(时长/封面/fee 取旁路) */ +int appstate_cache_remove(int i); /* 删文件 + 摘除条目;0=成功 */ +void appstate_cache_clear(void); /* 清空缓存池(含封面池,同设置页语义)+ 清列表 */ + /* ---- 搜索结果独立数组(§23;g_search_mu 保护,UI/worker 经此访问)---- */ int appstate_search_count(int tab); /* tab: 0=单曲 1=歌单 */ int appstate_search_offset(int tab); /* 下次 offset(=已加载条数) */ diff --git a/patch_v2/ncm_app/app/play/ipc_media.c b/patch_v2/ncm_app/app/play/ipc_media.c index 63eddb7..82e6c59 100644 --- a/patch_v2/ncm_app/app/play/ipc_media.c +++ b/patch_v2/ncm_app/app/play/ipc_media.c @@ -242,6 +242,7 @@ static const char *ncm_act_name(int act) case ACT_PLAYER: return "player"; case ACT_SETTINGS: return "settings"; case ACT_SEARCH: return "search"; + case ACT_CACHE: return "cache"; /* M5 已缓存列表(app.json 未声明常亮 → 照常息屏) */ default: return NULL; } } diff --git a/patch_v2/ncm_app/app/play/player_manager.c b/patch_v2/ncm_app/app/play/player_manager.c index 671928e..9a13716 100644 --- a/patch_v2/ncm_app/app/play/player_manager.c +++ b/patch_v2/ncm_app/app/play/player_manager.c @@ -167,9 +167,10 @@ void player_manager_set_quality(int level, int cache_gb) static int play_is_same_song(int song_idx) { ncm_song_t cand; - int have = (appstate_get_queue() == NCM_QUEUE_SEARCH) - ? (appstate_search_song(song_idx, &cand) == NCM_OK) - : (appstate_song_copy(song_idx, &cand) == NCM_OK); + int src = appstate_get_queue(); + int have = (src == NCM_QUEUE_SEARCH) ? (appstate_search_song(song_idx, &cand) == NCM_OK) + : (src == NCM_QUEUE_CACHE) ? (appstate_cache_song(song_idx, &cand) == NCM_OK) + : (appstate_song_copy(song_idx, &cand) == NCM_OK); if (!have || !cand.id[0]) return 0; /* 未物化:不能确证同曲 → 交下方正常起播 */ pthread_mutex_lock(&g_play.mu); int same = g_play.play_song_valid && g_play.play_song.id[0] && @@ -645,6 +646,31 @@ static int song_local_file(const ncm_song_t *s, int idx, int seq, song_src_t *ss char key[64]; audio_cache_key(key, sizeof key, id, g_level); + /* 已缓存列表点播(NCM_QUEUE_CACHE):用条目**真实键**(id+文件名请求档)与实际扩展名直接 + * 命中,不走用户当前档键——否则别的档位/降级产物条目会 miss 而被重下(与「点缓存就该放缓存」 + * 相悖,也会白耗流量)。条目已被删/被 LRU 淘汰 → NOENT(不重下,UI 报错)。 */ + if (appstate_get_queue() == NCM_QUEUE_CACHE) { + ncm_cache_item_t it; + if (appstate_cache_item(idx, &it) == NCM_OK) { + char ck[48]; + if (it.level[0]) snprintf(ck, sizeof ck, "%s_%s", it.id, it.level); + else ncm_strlcpy(ck, it.id, sizeof ck); + if (ncm_cache_lookup(&g_st.ctx.cache, ck, it.ext, path, psz) == NCM_OK) { + struct stat st; + if (stat(path, &st) == 0 && + stream_src_open_static(&ss->src, path, (uint64_t)st.st_size) == 0) { + ss->total = (int64_t)st.st_size; + *bytes = ss->total; + pthread_mutex_lock(&g_play.mu); + g_play.eff_level = it.actual_level; /* 实际档(非请求档),UI 标注用 */ + pthread_mutex_unlock(&g_play.mu); + return NCM_OK; + } + } + } + return NCM_ERR_NOENT; + } + /* 缓存命中 → 静态 stream_src(新键两扩展名都试;档位=用户设定档,降级产物也归该档键) */ int hit = 0; if (ncm_cache_lookup(&g_st.ctx.cache, key, "flac", path, psz) == NCM_OK || 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 e031076..9332752 100644 --- a/patch_v2/ncm_app/app/test/test_ncm_app.c +++ b/patch_v2/ncm_app/app/test/test_ncm_app.c @@ -72,6 +72,37 @@ static void mock_lyric(void) lyric_parse(lrc, tl); } +/* M5:已缓存列表页 mock —— 造 3 条 songs/ 缓存(mp3 帧 + 嵌入 ID3v2.3),再同步扫描进快照。 */ +static void mock_cache(void) +{ + static const char *names[] = { "Song Cached One", "Song Cached Two", "Song Cached Three" }; + static const char *arts[] = { "Artist X", "Artist Y", "Artist Z" }; + static const char *lvks[] = { "exhigh", "hires", "lossless" }; + ncm_cache_init(&g_st.ctx.cache, g_st.cache_dir); + for (int i = 0; i < 3; i++) { + char id[24], key[48]; + unsigned char fr[4096]; + ncm_cache_writer_t *w; + snprintf(id, sizeof id, "%ld", 5000L + i); + snprintf(key, sizeof key, "%s_%s", id, lvks[i]); + for (size_t k = 0; k < sizeof fr; k++) fr[k] = (unsigned char)(0x20 + (k * 7)); + fr[0] = 0xFF; fr[1] = 0xFB; fr[2] = 0xE0; fr[3] = 0x00; /* 320kbps / 44.1kHz */ + if (ncm_cache_open(&g_st.ctx.cache, key, "mp3", &w) != NCM_OK) continue; + ncm_cache_write(w, fr, sizeof fr); + if (ncm_cache_commit(w) != NCM_OK) continue; + char path[NCM_CACHE_DIR_MAX + 64]; + if (ncm_cache_lookup(&g_st.ctx.cache, key, "mp3", path, sizeof path) == NCM_OK) { + ncm_tag_meta_t t; + memset(&t, 0, sizeof t); + snprintf(t.title, sizeof t.title, "%s", names[i]); + snprintf(t.artist, sizeof t.artist, "%s", arts[i]); + snprintf(t.album, sizeof t.album, "Album %d", i + 1); + ncm_tag_embed_file(path, "mp3", &t); + } + } + appstate_cache_list_load(); +} + static void mock_qr(void) { g_st.qr_size = 29; @@ -137,6 +168,7 @@ static int page_id(const char *name) if (strcmp(name, "searchtap") == 0) return ACT_SEARCH; /* §23 误触抑制自检 */ if (strcmp(name, "modetest") == 0) return ACT_PLAYER; /* §NEW 播放模式三态逻辑自检 */ if (strcmp(name, "queuetest") == 0) return ACT_QUEUE; /* §NEW 播放队列页/点播语义自检 */ + if (strcmp(name, "cache") == 0 || strcmp(name, "cachetest") == 0) return ACT_CACHE; /* M5 */ return -1; } @@ -268,8 +300,7 @@ int main(int argc, char **argv) appstate_search_history_add("Jay \u5553\u8868"); appstate_search_history_add("lofi beat"); appstate_search_history_add("Jay \u5553\u8868"); /* 去重 → 仍 3 条 */ - } else if (strcmp(name, "modetest") == 0 || strcmp(name, "queuetest") == 0) { - /* §NEW 播放模式 / 播放队列自检:歌单滑窗队列(6 首)+ 播放态 */ + } else if (strcmp(name, "modetest") == 0 || strcmp(name, "queuetest") == 0) { /* §NEW 播放模式 / 播放队列自检:歌单滑窗队列(6 首)+ 播放态 */ mock_playlists(); mock_songs(); pthread_mutex_lock(&g_play.mu); g_play.state = NCM_PC_PLAYING; @@ -290,6 +321,8 @@ int main(int argc, char **argv) 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, "cache") == 0 || strcmp(name, "cachetest") == 0) { + mock_cache(); /* M5:假缓存池 + 文件内 tag + 快照扫描 */ } else { mock_playlists(); } @@ -301,6 +334,7 @@ int main(int argc, char **argv) strcmp(name, "barhit") == 0 || strcmp(name, "covswitch") == 0) v = playlist_view_create(); else if (page_id(name) == ACT_PLAYER) v = player_view_create(); /* player/lands/playerbuf/playererr/modetest */ else if (page_id(name) == ACT_QUEUE) v = queue_view_create(); /* §NEW 播放队列页 */ + else if (page_id(name) == ACT_CACHE) v = cache_view_create(); /* M5 已缓存列表页 */ else if (strcmp(name, "settings") == 0) v = settings_view_create(); else if (strcmp(name, "search") == 0 || strcmp(name, "searchhist") == 0 || strcmp(name, "searchpl") == 0 || strcmp(name, "searchtap") == 0) v = search_view_create(); diff --git a/patch_v2/ncm_app/app/ui/cache.c b/patch_v2/ncm_app/app/ui/cache.c new file mode 100644 index 0000000..fb791f8 --- /dev/null +++ b/patch_v2/ncm_app/app/ui/cache.c @@ -0,0 +1,412 @@ +/* ui/cache.c — 已缓存列表页(ACT_CACHE,首页顶栏「已缓存」钮进入)。 + * + * 数据源:songs/ 音频池(键=歌曲ID+请求档位,NCM_CACHE_DESIGN §2.5)。扫描在 worker: + * ncm_cache_scan(读目录 + 文件头 tag + 音频参数探测)→ appstate 快照; + * UI 线程只读快照(零磁盘 IO),列表由 meow_listview 虚拟化。 + * 行:歌名 / 歌手 · **实际档位** · 大小。**档位显示实际档而非请求档**:缓存名里的档位段只是 + * 请求档(真机存在 song__hires.mp3 = 无损请求被降级链落到 exhigh mp3),实际档由 + * 文件头(FLAC bps/采样率、MP3 首帧码率)推断。 + * 交互:点行=点播(NCM_QUEUE_CACHE,上/下一首在缓存列表内循环);行右「删除」=单曲删除; + * 顶栏「清空」=清空缓存池(确认框,与设置页「清理缓存」同语义)。 + * 主题/控件惯例同 queue.c:圆角卡 + 红 mini-vinyl + 按压红条 + 当前播放行高亮。 + * 左缘返回=弹回首页(app.c)。 + */ +#include "../ncm_app_internal.h" +#include +#include +#include +#include + +typedef struct { meow_view *view; int active; } cache_ctrl; +static cache_ctrl g_cc; + +static meow_widget *g_tb, *g_list; +static ncm_rect_t r_cont, r_list; +static int g_bg_id, g_draw_id, g_empty_id, g_footer_id; +static int g_press_row, g_press_delta, g_dragging, g_drag_last_y; +static int g_last_count; + +/* 扫描 worker(磁盘 IO 不进 UI 线程;gen 计数做完成信号) */ +static pthread_mutex_t g_cmu = PTHREAD_MUTEX_INITIALIZER; +static pthread_t g_cthr; +static int g_cthr_on; +static volatile int g_cgen, g_cacked; +static volatile int g_cloading; +static volatile int g_cerr; + +static void c_drag_reset(void) +{ + g_press_row = -1; g_press_delta = 0; g_dragging = 0; g_drag_last_y = 0; +} + +/* 字节数 → "12.3 MB"(整数运算,不引浮点 printf;MIPS 软浮点省体积)。 */ +static void fmt_size(uint64_t n, char *out, size_t sz) +{ + if (n >= (1024ULL * 1024ULL * 1024ULL)) { + uint64_t t = (n * 10ULL + 512ULL * 1024ULL * 1024ULL) / (1024ULL * 1024ULL * 1024ULL); + snprintf(out, sz, "%llu.%llu GB", (unsigned long long)(t / 10), (unsigned long long)(t % 10)); + } else if (n >= 1024ULL * 1024ULL) { + uint64_t t = (n * 10ULL + 512ULL * 1024ULL) / (1024ULL * 1024ULL); + snprintf(out, sz, "%llu.%llu MB", (unsigned long long)(t / 10), (unsigned long long)(t % 10)); + } else { + snprintf(out, sz, "%llu KB", (unsigned long long)(n / 1024ULL)); + } +} + +static const char *level_label(int lv) +{ + return (lv >= 0) ? ncm_level_name(lv) : "未知档"; +} + +/* 列表数据源(逐行快照读取,零 IO)。 */ +static int c_src2(int idx, meow_list_item *out, void *ctx) +{ + (void)ctx; + static ncm_cache_item_t it; + static char sub[192]; + static char title[100]; + char sz[24]; + + if (appstate_cache_item(idx, &it) != NCM_OK) return 0; + fmt_size(it.size, sz, sizeof sz); + snprintf(sub, sizeof sub, "%s · %s · %s", + it.artist[0] ? it.artist : "-", level_label(it.actual_level), sz); + snprintf(title, sizeof title, "%s", it.name[0] ? it.name : "(未知歌曲)"); + out->icon_kind = MEOW_LIST_ICON_NONE; /* 图标槽由红 overlays 画迷你黑胶 */ + out->title = title; + out->subtitle = sub; + out->right = MEOW_LIST_RIGHT_NONE; /* 右侧「删除」由 z=60 叠层自绘 */ + out->on = 0; out->enabled = 1; + return 1; +} + +/* 行背景(z=5,画在 listview 文本之下):圆角卡 + 当前播放行红色高亮。 */ +static void c_row_bg(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_CACHE) return; + int cnt = appstate_cache_count(); + if (cnt <= 0) return; + const meow_theme *th = meow_theme_get(); + const ncm_pal_t *pa = ncm_palette(g_dark); + 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 > cnt) last = cnt; + int cur = (appstate_get_queue() == NCM_QUEUE_CACHE) ? player_manager_playing_song() : -1; + for (int i = first; i < last; i++) { + int ry = ncm_row_card_y(item, i, off, r_list.y); + int th2 = ncm_row_card_h(item); + if (ry < r_list.y || ry + th2 > r_list.y + r_list.h) continue; /* 只画完整落在视口内的行 */ + if (i == cur) { + ncm_fill_round(r_list.x, ry, r_list.w, th2, 12, pa->red_ctr); + meow_ui_fill_rect(r_list.x + 2, ry + 4, 4, th2 - 8, pa->red); + } else { + ncm_fill_round(r_list.x, ry, r_list.w, th2, 12, th->surfaceContainer); + } + } +} + +/* 主绘制(z=60):迷你黑胶缩略 + 行右「删除」+ 按压红条;文本由 listview z=10 画。 */ +static void c_draw(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_CACHE) return; + const meow_theme *th = meow_theme_get(); + const ncm_pal_t *pa = ncm_palette(g_dark); + int cnt = appstate_cache_count(); + if (cnt <= 0) return; + 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 > cnt) last = cnt; + for (int i = first; i < last; i++) { + int ry = ncm_row_card_y(item, i, off, r_list.y); + int th2 = ncm_row_card_h(item); + if (ry < r_list.y || ry + th2 > r_list.y + r_list.h) continue; + int icy = ry + (th2 - isz) / 2, bx = r_list.x + 16; + ncm_fill_round(bx, icy, isz, isz, 6, pa->red); + meow_ui_fill_rect(bx + isz / 2 - 2, icy + isz / 2 - 2, 4, 4, pa->on_red); /* 盘芯 */ + /* 行右「删除」(触达区 = 右 48px,见 cache_on_tap) */ + meow_ui_text_right(r_list.x + r_list.w - APP_PAD, + ry + (th2 - 18) / 2, "删除", 18, pa->red); + if (g_press_row == i) { + meow_ui_fill_rect(r_list.x, ry, 4, th2, pa->red); + meow_ui_fill_rect_alpha(r_list.x, ry, r_list.w, th2, pa->red, 26); + } + /* 擦除 z=10 的 SDK 行分隔线(现落进卡片间空隙):以页底色填空隙带;不越出列表视口底。 */ + int gy = ry + th2, gh = NCM_ROW_GAP; + if (gy + gh > r_list.y + r_list.h) gh = r_list.y + r_list.h - gy; + if (gh > 0) meow_ui_fill_rect(r_list.x, gy, r_list.w, gh, th->bg); + } +} + +static void c_draw_empty(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_CACHE) return; + if (appstate_cache_count() != 0) return; + if (g_cloading) { ncm_draw_empty(r_list, "LOADING", "正在扫描缓存..."); return; } + if (g_cerr) { ncm_draw_empty(r_list, "ERROR", "缓存目录读取失败"); return; } + ncm_draw_empty(r_list, "暂无缓存", "播放过的歌曲会自动缓存"); +} + +static void c_draw_footer(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_CACHE) return; + int n = appstate_cache_count(); + if (n <= 0) return; + uint64_t total = 0; + ncm_cache_item_t it; + for (int i = 0; i < n; i++) + if (appstate_cache_item(i, &it) == NCM_OK) total += it.size; + char sz[24], foot[96]; + fmt_size(total, sz, sizeof sz); + snprintf(foot, sizeof foot, "%d 首 · 合计 %s", n, sz); + ncm_draw_footer(r_cont, foot); +} + +static void c_apply(void) +{ + if (!g_list) return; + int n = appstate_cache_count(); + meow_listview_set_count(g_list, n); + if (n != g_last_count) { + g_last_count = n; + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); + } + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->bg); +} + +static void *cache_worker(void *arg) +{ + (void)arg; + int rc = appstate_cache_list_load(); /* 扫描 + 文件内 tag + 旁路补名:全部磁盘 IO */ + pthread_mutex_lock(&g_cmu); + g_cerr = (rc < 0) ? rc : 0; + g_cloading = 0; + g_cgen++; + pthread_mutex_unlock(&g_cmu); + return NULL; +} + +void cache_reload(void) +{ + if (g_host_mock) { /* 宿主渲染:同步扫描(无线程/无延时,截图确定) */ + int rc = appstate_cache_list_load(); + g_cerr = (rc < 0) ? rc : 0; + g_cloading = 0; + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); + return; + } + if (g_cthr_on) return; + g_cloading = 1; + g_cthr_on = 1; + if (ncm_thread_create(&g_cthr, cache_worker, NULL) != 0) { + g_cthr_on = 0; g_cloading = 0; + g_cerr = NCM_ERR_IO; + } + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); +} + +/* 单曲删除(行右「删除」):删文件 + 摘除快照项 + 提示。 */ +static void cache_delete_row(int row) +{ + ncm_cache_item_t it; + if (appstate_cache_item(row, &it) != NCM_OK) return; + char name[96], msg[160]; + ncm_strlcpy(name, it.name[0] ? it.name : it.id, sizeof name); + if (appstate_cache_remove(row) == 0) { + snprintf(msg, sizeof msg, "已删除:%s", name); + ncm_snack(msg); + c_apply(); + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); + } else { + ncm_snack("删除失败"); + } +} + +/* 清空(顶栏「清空」):确认框(设置页「清理缓存」同语义:删除全部音频 + 封面缓存条目)。 */ +static void cache_confirm_clear(void) +{ + int n = appstate_cache_count(); + if (n <= 0) { ncm_snack("缓存已为空"); return; } + uint64_t total = 0; + ncm_cache_item_t it; + for (int i = 0; i < n; i++) + if (appstate_cache_item(i, &it) == NCM_OK) total += it.size; + char sz[24], msg[192]; + fmt_size(total, sz, sizeof sz); + snprintf(msg, sizeof msg, "将删除全部 %d 首已缓存歌曲 (%s) 及封面缓存。\n已播放的歌曲需重新下载。", + n, sz); + meow_dialog *d = meow_dialog_create(MEOW_DLG_CONFIRM, "清空缓存?"); + const char *msgs[1]; + msgs[0] = msg; + meow_dialog_set_items(d, msgs, 1, -1); + int yes = meow_dialog_confirm(d); + meow_dialog_free(d); + if (!yes) return; + appstate_cache_clear(); + ncm_snack("缓存已清理"); + c_apply(); + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); +} + +void cache_open(void) +{ + meow_view_page_push(ACT_CACHE); + cache_reload(); + c_apply(); + meow_ui_paint_full(); +} + +int cache_is_active(void) { return g_cc.active; } + +int cache_on_tap(int x, int y) +{ + if (g_tb) { + int ah = meow_topappbar_hit(g_tb, x, y); + if (ah == MEOW_TOPBAR_BACK) { c_drag_reset(); meow_view_page_pop(); return 1; } + if (ah == 0) { c_drag_reset(); cache_confirm_clear(); return 1; } /* 文本动作「清空」 */ + } + if (meow_widget_hit(g_list, x, y)) { + int was_drag = (g_press_delta > 10); + int row = (g_press_row >= 0) ? g_press_row : meow_listview_hit(g_list, x, y); + int del = (x >= r_list.x + r_list.w - APP_OP_W); + c_drag_reset(); + if (row < 0 || was_drag) return 1; + if (del) { cache_delete_row(row); return 1; } + player_play_cache_song(row); + return 1; + } + return 0; +} + +void cache_touch_down(int x, int y) +{ + c_drag_reset(); + if (appstate_cache_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; + g_dragging = 1; g_drag_last_y = y; + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); + } +} + +void cache_touch_move(int x, int y) +{ + (void)x; + if (!g_dragging) return; + if (g_press_row >= 0) { + int d = y - g_drag_last_y; if (d < 0) d = -d; + g_press_delta += d; + if (g_press_delta > 10) g_press_row = -1; + } + int dy = y - g_drag_last_y; g_drag_last_y = y; + if (dy == 0) return; + int before = meow_listview_scroll(g_list); + 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); +} + +void cache_swipe(int dir, int dist) +{ + if (dir != MEOW_SWIPE_UP && dir != MEOW_SWIPE_DOWN) return; + if (appstate_cache_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); +} + +void cache_tick(uint64_t now) +{ + (void)now; + chrome_tick(); + if (!g_cc.active) return; + if (!g_cthr_on) return; + int gen, acked; + pthread_mutex_lock(&g_cmu); + gen = g_cgen; acked = g_cacked; + pthread_mutex_unlock(&g_cmu); + if (gen == acked) return; + pthread_mutex_lock(&g_cmu); + g_cacked = gen; + pthread_mutex_unlock(&g_cmu); + g_cthr_on = 0; + if (g_cerr) ncm_snack("缓存读取失败"); + c_apply(); + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); +} + +/* 生命周期 */ +static void c_page_create(meow_view *v, void *ctx) +{ + (void)v; + ((cache_ctrl *)ctx)->view = v; + r_cont = ncm_content_rect(); + r_list = NR(16, r_cont.y + 8, r_cont.w - 32, + r_cont.y + r_cont.h - APP_FOOTER_H - (r_cont.y + 8)); + if (r_list.h < 0) r_list.h = 0; + meow_ui_activity_push(ACT_CACHE); + g_tb = ncm_topbar_create("已缓存", 1, "清空"); /* 文本动作(动作0)=清空缓存 */ + g_list = meow_listview_create(r_list.x, r_list.y, r_list.w, r_list.h, 0, + c_src2, NULL, MEOW_LIST_SEL_NONE); + meow_listview_set_item_h(g_list, APP_ROW_H); + meow_ui_activity_pop(); + meow_widget_set_z(g_list, 10); + g_bg_id = meow_draw_custom_register(5, r_list.x, r_list.y, r_list.w, r_list.h, + c_row_bg, NULL); + g_draw_id = meow_draw_custom_register(60, r_cont.x, r_cont.y, r_cont.w, r_cont.h, + c_draw, NULL); + g_empty_id = meow_draw_custom_register(1, r_list.x, r_list.y, r_list.w, r_list.h, + c_draw_empty, NULL); + g_footer_id = meow_draw_custom_register(1, r_cont.x, r_cont.y + r_cont.h - APP_FOOTER_H, + r_cont.w, APP_FOOTER_H, c_draw_footer, NULL); + c_drag_reset(); +} + +static void c_page_enter(meow_view *v, void *ctx) +{ + (void)v; + ((cache_ctrl *)ctx)->active = 1; + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->bg); + g_last_count = -1; + cache_reload(); /* 每次进入重扫(播放/删除后 LRU 变化) */ + c_apply(); + meow_ui_paint_full(); +} + +static void c_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((cache_ctrl *)ctx)->active = 0; +} + +static void c_page_destroy(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + if (g_bg_id) { meow_draw_unregister(g_bg_id); g_bg_id = 0; } + if (g_draw_id) { meow_draw_unregister(g_draw_id); g_draw_id = 0; } + if (g_empty_id) { meow_draw_unregister(g_empty_id); g_empty_id = 0; } + if (g_footer_id) { meow_draw_unregister(g_footer_id); g_footer_id = 0; } + if (g_list) { meow_widget_free(g_list); g_list = NULL; } + if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } +} + +meow_view *cache_view_create(void) +{ + static const meow_view_hooks h = { + .on_create = c_page_create, + .on_enter = c_page_enter, + .on_leave = c_page_leave, + .on_destroy = c_page_destroy, + }; + return meow_view_create(ACT_CACHE, MEOW_VIEW_ACTIVITY, &h, &g_cc); +} diff --git a/patch_v2/ncm_app/app/ui/home.c b/patch_v2/ncm_app/app/ui/home.c index 1fca710..cd6947d 100644 --- a/patch_v2/ncm_app/app/ui/home.c +++ b/patch_v2/ncm_app/app/ui/home.c @@ -1,9 +1,10 @@ /* ui/home.c — 我的音乐页(ACT_HOME,根页,已登录)。 * - * 网易云风格(NCM_UI_DESIGN §4.2):红系 topbar(标题「我的音乐」+搜索/刷新/设置/回播放页动作)+ + * 网易云风格(NCM_UI_DESIGN §4.2):红系 topbar(标题「我的音乐」+刷新/设置/搜索/已缓存/回播放页动作)+ * 个人区卡片(头像/昵称/ID)+ 双页签「我创建的/我收藏的」(meow_tabbar + 各自 meow_listview)+ * 组头红条 + 歌单行(rounded card + 红 mini-vinyl 缩略 + 红色按压竖条)。 - * 批6:顶栏「回播放页」按钮(sb_icons 位图,动作3 最右)——无活动音频也生效,按下 push(ACT_PLAYER)。 + * 批6:顶栏「回播放页」按钮(sb_icons 位图,动作4 最右)——无活动音频也生效,按下 push(ACT_PLAYER)。 + * M5:顶栏「已缓存」按钮(动作3,MEOW_ICON_DOWNLOAD 位图)→ 已缓存列表页 ACT_CACHE。 * * 品牌红走 ncm_palette(g_dark)(App 无权改 SDK MD3 蓝 primary),代码零硬编码散落: * 中性色读 meow_theme_get(),红读 ncm_palette()。SDK listview 的 MD3 蓝选中不可改, @@ -299,7 +300,8 @@ int home_on_tap(int x, int y) } if (ah == 1) { home_drag_reset(); settings_open(); return 1; } /* 设置 */ if (ah == 2) { home_drag_reset(); search_open(); return 1; } /* 搜索 */ - if (ah == 3) { home_drag_reset(); meow_view_page_push(ACT_PLAYER); return 1; } /* 回播放页 */ + if (ah == 3) { home_drag_reset(); cache_open(); return 1; } /* 已缓存列表 */ + if (ah == 4) { home_drag_reset(); meow_view_page_push(ACT_PLAYER); return 1; } /* 回播放页 */ } if (g_tabs && meow_widget_hit(g_tabs, x, y)) { int t = meow_tabbar_hit(g_tabs, x, y); @@ -376,15 +378,18 @@ static void home_page_create(meow_view *v, void *ctx) g_tb = ncm_topbar_create("我的音乐", 0, "\xe2\x86\xbb"); /* 刷新(动作0) */ meow_topappbar_add_action(g_tb, "\xe2\x9a\x99"); /* 设置(动作1) */ meow_topappbar_add_action(g_tb, "\xe2\x8c\x95"); /* 搜索(动作2) */ - /* 批6:回播放页(动作3, 最右)。无活动音频也生效——按下即 push(ACT_PLAYER),播放页按 appstate + /* M5:已缓存列表入口(动作3)——songs/ 池全部已缓存歌曲(点播/删除/清空)。 */ + meow_topappbar_add_action(g_tb, "\xe2\x87\xa9"); /* 已缓存(动作3) */ + /* 批6:回播放页(动作4, 最右)。无活动音频也生效——按下即 push(ACT_PLAYER),播放页按 appstate * 现有语义展示空态(暂无播放)/上次会话状态。 */ - meow_topappbar_add_action(g_tb, "\xe2\x99\xaa"); /* 回播放页(动作3, 最右) */ + meow_topappbar_add_action(g_tb, "\xe2\x99\xaa"); /* 回播放页(动作4, 最右) */ /* §307 topbar 图标迁移:三位码点(↻ ⚙ ⌕)在 4k 字库缺字形渲染成豆腐(WORK_STATE 310 前置 * 症状①),改为 sb_icons 位图(Material Symbols Rounded,字符标签仅作占位,绘制走位图)。 */ meow_topappbar_set_item_icon(g_tb, 0, MEOW_ICON_REFRESH); meow_topappbar_set_item_icon(g_tb, 1, MEOW_ICON_SETTINGS); meow_topappbar_set_item_icon(g_tb, 2, MEOW_ICON_SEARCH); - meow_topappbar_set_item_icon(g_tb, 3, MEOW_ICON_QUEUE_MUSIC); /* 回播放页 */ + meow_topappbar_set_item_icon(g_tb, 3, MEOW_ICON_DOWNLOAD); /* 已缓存列表 */ + meow_topappbar_set_item_icon(g_tb, 4, MEOW_ICON_QUEUE_MUSIC); /* 回播放页 */ g_tabs = meow_tabbar_create(r_tabs.x, r_tabs.y, r_tabs.w, r_tabs.h); meow_tabbar_add_tab(g_tabs, tab_label(0)); meow_tabbar_add_tab(g_tabs, tab_label(1)); diff --git a/patch_v2/ncm_app/app/ui/player.c b/patch_v2/ncm_app/app/ui/player.c index d513235..c1686ec 100644 --- a/patch_v2/ncm_app/app/ui/player.c +++ b/patch_v2/ncm_app/app/ui/player.c @@ -783,6 +783,8 @@ void player_play_queue_song(int idx) int have = 0; if (appstate_get_queue() == NCM_QUEUE_SEARCH) { if (appstate_search_song(idx, &s) == 0) have = 1; + } else if (appstate_get_queue() == NCM_QUEUE_CACHE) { + if (appstate_cache_song(idx, &s) == 0) have = 1; } else if (appstate_song_copy(idx, &s) == 0) { have = 1; } @@ -794,6 +796,15 @@ void player_play_queue_song(int idx) meow_view_page_push(ACT_PLAYER); } +/* 已缓存列表点播(ACT_CACHE 页点行):切 NCM_QUEUE_CACHE 队列后走同一套「队列点播」路径—— + * 上/下一首在缓存列表内循环;播放线程按条目真实键直读缓存文件(不重下)。 */ +void player_play_cache_song(int i) +{ + if (i < 0 || i >= appstate_cache_count()) return; + appstate_set_queue(NCM_QUEUE_CACHE); + player_play_queue_song(i); +} + void player_pause_toggle(int on) { if (player_manager_state() == NCM_PC_PAUSED) player_manager_pause(0); diff --git a/patch_v2/ncm_app/app/ui/queue.c b/patch_v2/ncm_app/app/ui/queue.c index ce56af2..199c3f8 100644 --- a/patch_v2/ncm_app/app/ui/queue.c +++ b/patch_v2/ncm_app/app/ui/queue.c @@ -1,7 +1,7 @@ /* ui/queue.c — 当前实时播放队列页(ACT_QUEUE,§NEW:播放页「队列」钮进入)。 * - * 展示**当前播放队列**(player_manager 的 queue_src —— 歌单滑动窗口 或 搜索单曲结果集), - * 含当前曲高亮、可点某行切到那首播放。数据源与分页复用歌单滑动窗口(REPORT §17): + * 展示**当前播放队列**(player_manager 的 queue_src —— 歌单滑动窗口 / 搜索单曲结果集 / + * 已缓存列表),含当前曲高亮、可点某行切到那首播放。数据源与分页复用歌单滑动窗口(REPORT §17): * - count = appstate_queue_count()(全量:track_ids_count 或 search_song_count,可 5194 首); * - 行内容 = src2 逐行按 queue_src 取(PLAYLIST→滑窗拷贝,SEARCH→搜索数组),未物化槽显示 * ".../加载中" 占位,视口 ±24 行未物化 → 后台 worker ensure_range 补载(盘缓存优先,批≤NCM_SONG_BATCH); @@ -47,8 +47,11 @@ static void q_drag_reset(void) * PLAYLIST:滑窗拷贝(UI 线程不触发网络);SEARCH:搜索数组(内存)。 */ static int q_song_get(int idx, ncm_song_t *out) { - if (appstate_get_queue() == NCM_QUEUE_SEARCH) + int src = appstate_get_queue(); + if (src == NCM_QUEUE_SEARCH) return (appstate_search_song(idx, out) == 0) ? 0 : -1; + if (src == NCM_QUEUE_CACHE) + return (appstate_cache_song(idx, out) == 0) ? 0 : -1; return (appstate_song_copy(idx, out) == 0) ? 0 : -1; } @@ -195,7 +198,7 @@ static void *queue_worker(void *arg) * SEARCH 队列全在内存,无需补载。 */ static void q_maybe_ensure(void) { - if (appstate_get_queue() != NCM_QUEUE_PLAYLIST) return; + if (appstate_get_queue() != NCM_QUEUE_PLAYLIST) return; /* SEARCH/CACHE 队列全在内存 */ if (g_qthr_on || g_host_mock) return; int total = appstate_queue_count(); if (total <= 0) return; diff --git a/patch_v2/ncm_app/build_ncm.sh b/patch_v2/ncm_app/build_ncm.sh index 4e99a8c..189ae09 100755 --- a/patch_v2/ncm_app/build_ncm.sh +++ b/patch_v2/ncm_app/build_ncm.sh @@ -18,7 +18,7 @@ CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \ core/ncm_crypto.c core/ncm_req.c core/ncm_login.c core/ncm_api.c \ core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c core/ncm_tag.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/minibar.c app/ui/queue.c \ + app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/queue.c app/ui/cache.c \ app/ui/ncm_colors.c app/ui/chrome.c \ app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \ app/logic/cover.c app/logic/account.c app/play/player_manager.c app/play/audio_out.c \ diff --git a/patch_v2/ncm_app/build_ncm_host.sh b/patch_v2/ncm_app/build_ncm_host.sh index 1fbd1eb..95f57c4 100755 --- a/patch_v2/ncm_app/build_ncm_host.sh +++ b/patch_v2/ncm_app/build_ncm_host.sh @@ -30,7 +30,8 @@ $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/minibar.c app/ui/queue.c app/ui/ncm_colors.c app/ui/chrome.c \ + app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/queue.c app/ui/cache.c \ + app/ui/ncm_colors.c app/ui/chrome.c \ app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \ app/logic/cover.c app/logic/account.c app/play/player_manager.c app/play/audio_out.c \ app/play/ipc_media.c app/play/decoder.c app/play/stream_src.c app/play/minimp3_impl.c" diff --git a/patch_v2/ncm_app/core/ncm_api.c b/patch_v2/ncm_app/core/ncm_api.c index e9d064f..e8be2fb 100644 --- a/patch_v2/ncm_app/core/ncm_api.c +++ b/patch_v2/ncm_app/core/ncm_api.c @@ -39,6 +39,22 @@ const char *ncm_api_level_key(int level) { return ncm_api_level_str(level); } +/* 键后缀 → 档位枚举(缓存扫描/「已缓存列表」用;与 ncm_api_level_key 严格互逆)。 */ +int ncm_api_level_from_key(const char *key) { + if (!key || !key[0]) return -1; + if (!strcmp(key, "sky_c51")) return NCM_LEVEL_SURROUND; + if (!strcmp(key, "sky")) return NCM_LEVEL_SKY; + if (!strcmp(key, "jymaster")) return NCM_LEVEL_JYMASTER; + if (!strcmp(key, "jyeffect")) return NCM_LEVEL_JYEFFECT; + if (!strcmp(key, "dolby")) return NCM_LEVEL_DOLBY; + if (!strcmp(key, "hires")) return NCM_LEVEL_HIRES; + if (!strcmp(key, "lossless")) return NCM_LEVEL_LOSSLESS; + if (!strcmp(key, "exhigh")) return NCM_LEVEL_EXHIGH; + if (!strcmp(key, "higher")) return NCM_LEVEL_HIGHER; + if (!strcmp(key, "standard")) return NCM_LEVEL_STANDARD; + return -1; +} + int ncm_api_level_gate(int level) { switch (level) { case NCM_LEVEL_LOSSLESS: diff --git a/patch_v2/ncm_app/core/ncm_api.h b/patch_v2/ncm_app/core/ncm_api.h index 1978989..69bcd21 100644 --- a/patch_v2/ncm_app/core/ncm_api.h +++ b/patch_v2/ncm_app/core/ncm_api.h @@ -39,6 +39,8 @@ enum { }; const char *ncm_api_level_str(int level); /* API level 串(SKY/SURROUND 同为 "sky") */ const char *ncm_api_level_key(int level); /* 缓存键唯一后缀(SKY="sky"/SURROUND="sky_c51") */ +/* 缓存键后缀 → 档位枚举(ncm_api_level_key 的逆;未知/空返回 -1)。 */ +int ncm_api_level_from_key(const char *key); /* 会员等级(vip/info 判定,BIZ_NOTES §1):用户语义 0=非会员 / 1=畅听会员(VIP) / * 2=黑胶会员(CVIP) / 3=超级会员(SVIP)。枚举名保留旧名(CVIP/SVIP)避免大改,新增 diff --git a/patch_v2/ncm_app/core/ncm_cache.c b/patch_v2/ncm_app/core/ncm_cache.c index f105707..806fdb9 100644 --- a/patch_v2/ncm_app/core/ncm_cache.c +++ b/patch_v2/ncm_app/core/ncm_cache.c @@ -2,6 +2,8 @@ #include "ncm_cache.h" #include "ncm_common.h" #include "ncm_store.h" +#include "ncm_tag.h" +#include "ncm_api.h" #include #include @@ -461,3 +463,134 @@ int ncm_cache_clear(const ncm_cache_t *c) { } return NCM_OK; } + +/* ---- 「已缓存列表」:扫描/删除(键=歌曲ID+档位,NCM_CACHE_DESIGN §2.5)---- + * 文件名 song__<请求档位>.<实际格式>;**档位段只是请求档**,实际档由文件头探测得出 + * (真机 song__hires.mp3 = 无损请求被降级链落到 exhigh mp3)。 */ + +/* 解析条目名 → id/请求档位键/实际扩展名。song_ 后必须是数字 id(排除 song_cov* 封面)。 + * 返回 0=是歌曲条目。 */ +static int parse_item_name(const char *nm, ncm_cache_item_t *it) +{ + const char *p; + size_t n = 0; + if (!nm || strncmp(nm, "song_", 5) != 0) return -1; + p = nm + 5; + while (p[n] >= '0' && p[n] <= '9' && n + 1 < sizeof it->id) { it->id[n] = p[n]; n++; } + if (n == 0 || (p[n] >= '0' && p[n] <= '9')) return -1; + it->id[n] = 0; + p += n; + it->level[0] = 0; + if (*p == '_') { + size_t m = 0; + p++; + while (*p && *p != '.' && m + 1 < sizeof it->level) it->level[m++] = *p++; + it->level[m] = 0; + } + while (*p == '.') p++; /* 历史双点名 song_..mp3 归一 */ + if (!*p) return -1; + ncm_strlcpy(it->ext, p, sizeof it->ext); + return 0; +} + +/* 请求档 + 实际容器参数 → 实际档(只降不升:显示「已降级至 X」语义)。 */ +static int actual_level_of(int req, const ncm_audio_info_t *ai) +{ + int cap; + if (!ai || !ai->container[0]) return req; /* 探测失败:保留请求档 */ + if (strcmp(ai->container, "flac") == 0) { + if (ai->bits_per_sample <= 16) cap = NCM_LEVEL_LOSSLESS; + else if (ai->sample_rate > 96000) cap = NCM_LEVEL_JYMASTER; /* 192k/24 */ + else if (ai->sample_rate > 48000) cap = NCM_LEVEL_JYEFFECT; /* 96k/24 */ + else cap = NCM_LEVEL_HIRES; /* 48k/24 */ + } else if (strcmp(ai->container, "mp3") == 0) { + cap = (ai->bitrate_kbps > 128) ? NCM_LEVEL_EXHIGH : NCM_LEVEL_STANDARD; + } else { + return req; + } + if (req < 0) return cap; + return (req < cap) ? req : cap; +} + +static int cmp_item_mtime_desc(const void *a, const void *b) +{ + const ncm_cache_item_t *x = (const ncm_cache_item_t *)a; + const ncm_cache_item_t *y = (const ncm_cache_item_t *)b; + if (x->mtime > y->mtime) return -1; + if (x->mtime < y->mtime) return 1; + return strcmp(x->id, y->id); +} + +int ncm_cache_scan(const ncm_cache_t *c, ncm_cache_item_t *out, int max) +{ + const char *store; + DIR *d; + struct dirent *e; + int n = 0; + + if (!c || !out || max <= 0) return NCM_ERR_ARG; + store = (c->songs_dir[0]) ? c->songs_dir : c->dir; + d = opendir(store); + if (!d) return NCM_ERR_IO; + while ((e = readdir(d))) { + ncm_cache_item_t it; + char p[NCM_CACHE_DIR_MAX + NAME_MAX + 8]; + struct stat st; + ncm_audio_info_t ai; + ncm_tag_meta_t tag; + int slot; + + if (strncmp(e->d_name, "song_", 5) != 0) continue; + if (is_partial_name(e->d_name)) continue; /* .tmp/.part:写者管理域 */ + memset(&it, 0, sizeof it); + if (parse_item_name(e->d_name, &it) != 0) continue; + snprintf(p, sizeof p, "%s/%s", store, e->d_name); + if (stat(p, &st) != 0 || !S_ISREG(st.st_mode)) continue; + it.size = (uint64_t)st.st_size; + it.mtime = (int64_t)st.st_mtim.tv_sec; + if (n < max) { + slot = n; + } else { + /* 池内条目多于展示上限:只保留最近访问的 max 条 */ + int oldest = 0; + for (int i = 1; i < n; i++) + if (out[i].mtime < out[oldest].mtime) oldest = i; + if (it.mtime <= out[oldest].mtime) continue; + slot = oldest; + } + memset(&ai, 0, sizeof ai); + it.actual_level = actual_level_of(ncm_api_level_from_key(it.level), + (ncm_audio_probe_file(p, &ai) == NCM_OK) ? &ai : NULL); + if (ncm_tag_read_file(p, &tag) == NCM_OK) { /* 文件内 tag 优先(外部播放器同源) */ + ncm_strlcpy(it.name, tag.title, sizeof it.name); + ncm_strlcpy(it.artist, tag.artist, sizeof it.artist); + } + out[slot] = it; + if (n < max) n++; + } + closedir(d); + qsort(out, (size_t)n, sizeof *out, cmp_item_mtime_desc); + return n; +} + +int ncm_cache_remove(const ncm_cache_t *c, const char *id, const char *level, const char *ext) +{ + char key[48]; + char path[NCM_CACHE_DIR_MAX + 64]; + + if (!c || !id || !id[0]) return NCM_ERR_ARG; + if (level && level[0]) snprintf(key, sizeof key, "%s_%s", id, level); + else ncm_strlcpy(key, id, sizeof key); + if (ncm_cache_path(c, key, (ext && ext[0]) ? ext : "mp3", path, sizeof path) != NCM_OK) + return NCM_ERR_FULL; + if (unlink(path) == 0) return NCM_OK; + /* §19 历史双点名 song_..(scan 已归一成单点 ext)兜底删除 */ + { + char legacy[NCM_CACHE_DIR_MAX + 64]; + const char *e = (ext && ext[0]) ? ext : "mp3"; + const char *base = cache_base_dir(c, key); + int k = snprintf(legacy, sizeof legacy, "%s/song_%s..%s", base, key, e); + if (k > 0 && (size_t)k < sizeof legacy && unlink(legacy) == 0) return NCM_OK; + } + return NCM_ERR_NOENT; +} diff --git a/patch_v2/ncm_app/core/ncm_cache.h b/patch_v2/ncm_app/core/ncm_cache.h index 85c2b25..c485edf 100644 --- a/patch_v2/ncm_app/core/ncm_cache.h +++ b/patch_v2/ncm_app/core/ncm_cache.h @@ -87,6 +87,30 @@ uint64_t ncm_cache_total_bytes(const ncm_cache_t *c); /* 立即执行 LRU 淘汰(扫描 songs/;超上限则删最旧,直到 <= 上限;跳过 .tmp/.part 半成品)。 */ int ncm_cache_evict(const ncm_cache_t *c); +/* 缓存条目(ncm_cache_scan 输出,供「已缓存列表」展示/点播/删除)。 + * 命名约定 song__<请求档位>.<实际格式>;**档位段是请求档,不是实际档**——真机存在 + * song_171650_hires.mp3(无损请求被降级链落到 exhigh mp3)。故 scan 用 ncm_audio_probe_file + * 读实际容器参数,actual_level 给**实际档**(-1=无法判定);播放/删除仍用 id+level+ext 定位。 */ +typedef struct ncm_cache_item { + char id[24]; /* 歌曲 id(数字串) */ + char level[20]; /* 文件名里的请求档位键(""=旧无档位键条目) */ + char ext[8]; /* 实际容器扩展名(mp3/flac,去点) */ + int actual_level; /* 实际档 NCM_LEVEL_*(按容器/位深/码率推断;-1=未知) */ + uint64_t size; /* 文件字节数(含嵌入 tag) */ + int64_t mtime; /* 最后访问/写入(LRU 序) */ + char name[96]; /* 文件内 tag 标题(无 tag 为空,调用方可回退旁路元数据) */ + char artist[64]; /* 文件内 tag 歌手 */ +} ncm_cache_item_t; + +/* 扫描歌曲缓存池(songs/,跳过 .tmp/.part 半成品与非 song_<数字 id> 条目),按 mtime 降序 + * (最近播放/写入在前)写入 out(最多 max 条;池内更多时保留最近的 max 条)。返回条目数或负错误码。 + * 每条会读文件头(tag + 音频参数探测),属磁盘 IO——**必须在 worker 线程调用**。 */ +int ncm_cache_scan(const ncm_cache_t *c, ncm_cache_item_t *out, int max); + +/* 删除单条缓存:id+level(level 可为 "")+ext 定位 song_[_].。 + * 返回 0=已删除;NCM_ERR_NOENT=不存在。 */ +int ncm_cache_remove(const ncm_cache_t *c, const char *id, const char *level, const char *ext); + /* 清空缓存池:删除 songs/ 与 covers/ 的 song_* 全部文件(音频 + 封面条目 + 残留 .tmp/.part)。 */ int ncm_cache_clear(const ncm_cache_t *c);