diff --git a/patch_v2/ncm_app/REPORT.md b/patch_v2/ncm_app/REPORT.md index 0188244..548d954 100644 --- a/patch_v2/ncm_app/REPORT.md +++ b/patch_v2/ncm_app/REPORT.md @@ -216,3 +216,143 @@ a53e55f9cab828fd59b694330ec7371e core/ncm_login.c d81eda09640ff748b0a1c14607 - **播放地址/缓存仅验证到“请求-解析-无版权返回 null”**;未做真实音频下载落盘(缓存 open/write/commit 已由单元测试覆盖;真实 mp3 下载留给带 UI 的播放层用 `ncm_http_download`+`ncm_cache_*` 串联)。 - **栈用量**:单次 http 调用涉及 mbedtls ssl/净栈,峰值栈估几十 KB(与预研 demo 同级);App 线程栈需 ≥64KB,联调时以真机线程栈为准。 - **JSON 响应限幅**:歌单/歌曲详情走 1MB~2MB 堆缓冲 + token 计数后精确分配,不算常驻;封面/大 JSON 不整体驻留,封面下载另有 `max_bytes` 限幅(建议 ~256KB,未在本次接线)。 + +--- + +## 10. 网易云客户端 v1 = App + UI + 播放链路(`os.meow.ncm`) + +> 日期:2026-08-31。在 §9 核心库之上落地 App:UI(登录/歌单/播放/设置)+ 播放链路(URL 降级→缓存→minimp3→audio_out)+ 媒体会话 IPC。 +> 仅改动 `patch_v2/ncm_app/app/`;未动 sdk/、meow_launcherd/(并行域冻结);未碰 vendor/、core/(§9 核心库原样使用)。 +> 宿主 + qemu-mipsel 双边实测见 §10.5。未 commit/push/刷机/动设备。 + +### 10.1 包结构与页面 + +``` +patch_v2/ncm_app/ +├── app.json # {"name":"网易云音乐",exec:.../os.meow.ncm/ncm,package,version:0.1,version_code:1,framework:1,immersive:1,fonts:gbk} +├── build_ncm.sh # mipsel 静态 0 warning +├── build_ncm_host.sh # 宿主离屏自测(MEOW_UI_DUMP) +├── ncm # mipsel 静态 App(md5 见 §10.6) +├── app/app.c # main + 装配 + 事件分发(物理键不映射;左缘左滑=返回/退出) +├── app/ncm_app_internal.h # 内部共享头(ACT_*/布局/数据上限/播放运行态/各页与 play/ipc 接口) +├── app/ui/ +│ ├── chrome.c # TopAppBar/内容区几何/空态/页脚/底部提示条(ncm_snack) +│ ├── login.c # 登录页:后台 worker: unikey→二维码矩阵→轮询→803 落 cookie→login_status +│ ├── home.c # 我的歌单(meow_listview:folder图标+名称+曲目数副标题+箭头) +│ ├── playlist.c # 歌单详情(曲目列表:歌名+歌手;点击起播并推进播页) +│ ├── player.c # 播放页(封面+标题/歌手+进度条seek+播放/暂停/上下曲+滚动歌词;竖/横布局可切) +│ └── settings.c # 设置(音质 Hi-res/较高/标准 + 缓存上限 1~100GB + 退出登录) +├── app/logic/ +│ ├── appstate.c # ctx/cookie/uid/歌单/歌曲/设置加载与持久化(meow_config_*) +│ ├── lyric.c # LRC 时间轴解析(原文+翻译合并、排序、行定位;流式有界 NCM_LYRIC_LINES) +│ └── cover.c # 封面后台线程下载(≤288KB)+解码单张缓存 + 落盘 cover_current.jpg 供 launcher +├── app/play/ +│ ├── player_manager.c # 播放线程:URL 降级→缓存(命中即播/否则流式落盘)→minimp3→audio_out;暂停冻结/seek重开/上下曲 +│ ├── audio_out.c # 音频输出:ALSA 直开 hw:0,0(内核UAPI) / FILE(WAV) / NULL(测RT) +│ ├── ipc_media.c # 媒体会话 IPC:MSG_META 上报 + MSG_PLAYCTL 接收(meow_ipc.h 直接帧格式) +│ └── minimp3_impl.c # minimp3_ex 唯一实现 TU +└── app/test/ + ├── test_ncm_app.c # 宿主真实页面渲染(NCM_PAGE=login|loginlive|home|playlist|player|lands|settings) + └── test_decode.c # 解码链路(minimp3→audio_out FILE)验证 +``` + +**页面/导航**:根页 = 已登录 ? ACT_HOME : ACT_LOGIN。栈:HOME → PLAYLIST → PLAYER;SETTINGS 任意页可进。 +左缘左滑(`on_swipe LEFT`):PLAYER→pop 回 PLAYLIST;SETTINGS→pop;PLAYLIST→pop 回 HOME;HOME/LOGIN→`meow_ui_request_exit()` 退出 App。 + +### 10.2 关键实现 + +**登录**(`ui/login.c`):进入即起后台 worker(网络全在独立线程,UI 不卡)。`ncm_login_unikey`(eapi) → `ncm_login_qr_url` + `ncm_login_qr_matrix`(qrcodegen) → 渲染 37×37 位图(白底+黑模块 6px,居中)→ 每 1.5s `ncm_login_poll`(801/802/803/800)。803:捕获 set-cookie 合并 → `ncm_ctx_set_cookie` 加密落盘 → `ncm_login_status_check` 取 uid/nickname → 置 done → UI tick `meow_view_page_replace(ACT_HOME)`。800(过期)自动刷新二维码;网络失败 tap 重试。 + +**主页**(`ui/home.c`):`meow_listview` 虚拟化(folder 图标 + 名字 + `N tracks` 副标题 + 右侧箭头);空态/会话过期/错误绘制物;topbar 标题=昵称 + 「↻」刷新;行数据轻量(不下载封面缩略图)。点击行 → `playlist_open(idx)`。 + +**歌单详情**(`ui/playlist.c`):`ncm_api_playlist_detail`(trackIds) + 分批 `ncm_api_song_detail`(≤256 首,NCM_SONG_MAX);行=歌名+歌手;点击行 → `player_play_song(row)`(起播 + 推播放页)。 + +**播放页**(`ui/player.c`):封面(`cover_request` 后台解码,`meow_ui_img_cover` 居中裁方 220px)+ 题/歌手 + 进度条(手绘,点 bar seek)+ 控制钮(prev/play-pause/next)+ 滚动歌词(`lyric_line_at(pos)` 居中高亮)。**横竖屏可切**:device LCD 为竖屏 480×720,故为**布局**切换(竖=封面上/歌词下;横=封面左/信息歌词右),topbar 「⟳」触发,`player_set_screen()`。进度/状态/歌词每 tick 读 `player_manager_*`(跨线程 mu)。 + +**设置**(`ui/settings.c`):3 行卡片(音质/缓存上限/退出登录)。音质→BottomSheet(Hi-res/Higher/Standard);缓存→BottomSheet(1/2/5/10/20/30/50/100 GB);退出→confirm 弹窗 → `appstate_logout()` + replace ACT_LOGIN。持久化 `MeowData/os.meow.ncm/data/` 下 `ncm.level`/`ncm.cache_limit_gb`(键与核心库 `ncm_cache_load_setting` 对齐)。 + +### 10.3 播放链路(`app/play/player_manager.c`) + +``` +player_manager_play(idx) → 播放线程外层循环 → run_song(idx): + ① song_local_file: ncm_cache_lookup 命中 → 直接播(离线);否则 + ncm_api_song_url_auto(desired=设置音质, 逐档降级 hires→…→standard, 只收 mp3) + + ncm_http_download 流式写缓存 temp → ncm_cache_commit(先落盘再播) + ② mp3dec_ex_open(path, MP3D_SEEK_TO_BYTE) → audio_out_open(44.1k/16/2) + ③ 循环 mp3dec_ex_read → audio_out_write(pcm, frames) → frames_done→pos_ms(~200ms 节流更新) + 暂停=冻结(停读停写停推 pos,状态 PAUSED);seek=重开解码器+audio_out 并字节 offset 近似定位; + 上下曲/停止=打断 run_song 回外层;播完=自动顺延下一首(无则 STOPPED) +``` +- **缓存**:目录 `MeowData/os.meow.ncm/cache/`(`ncm_cache_*`),LRU 上限可配(默认 10GB,1~100)。 +- **audio_out**:宿主/qemu 无 `/dev/snd/pcmC0D0p` → 回退 FILE WAV(`NCM_AUDIO_SINK=file` / `NCM_AUDIO_OUT=`)或 NULL(`NCM_AUDIO_SINK=null`,仅测 RT)。设备走 ALSA 内核 UAPI 直开(`sound/asound.h`,masks/intervals 重索引已按 musl 头修正),**真机时序待验证**。 + +### 10.4 媒体会话 IPC(`app/play/ipc_media.c`,SDK 无媒体封装自写最小客户端) + +- 连接 `MEOW_IPC_SOCK`(缺省 `AF_UNIX /usr/data/meow_ipc.sock`)。 +- `MSG_HELLO`(app_id=`os.meow.ncm`, flags=`MF_PLAYBACK`) → 读 ACK。 +- 同 fd 每 ~1s 发 `MSG_META`(m_meta_t:title/artist/album/pos_ms/dur_ms/state=_1播 2暂停 0停 3缓冲,flags=MF_ART|MF_COVER(有 cover_current.jpg 时),artwork=该文件路径,app_id)。launcher 以最近活跃会话为准 → 通知中心媒体卡片 + Now Playing 页。 +- `poll(fd,POLLIN,200ms)` 收 `MSG_PLAYCTL` → cmd 1播放/2暂停/3上一首/4下一首/5seek → `player_manager_*`。 +- 无 launcher socket(宿主/qemu 无守护)→ log `ipc: no launcher socket` 并优雅返回。 + +### 10.5 验证记录(宿主 + qemu-mipsel 双向实测) + +| 项 | 方式 | 结果 | +|---|---|---| +| 各页 UI | 宿主离屏 `MEOW_UI_DUMP` + PIL 转 PNG 目检 | login/home/playlist/player/lands/settings 全部正确(截图 `app/out/shots/*.png`) | +| 登录真实二维码 | `NCM_PAGE=loginlive`:核心库真实 `ncm_login_unikey`→qr_matrix→渲染 | 真 unikey `40287b2e-9ef8-4f2e-8bf5-04febdaf631a`→37×37 可扫 QR(`out/shots/loginlive.png`) | +| mipsel 二进制 | `qemu-mipsel ./ncm`(离屏+MEOW_UI_DUMP,timeout SIGTERM 干净退出) | 真实 mipsel 静态二进制跑通:`start`→`ipc: no launcher socket`(优雅)→登录页渲染**真实可扫 QR**(`out/shots/mipsel_qemu.png`) | +| 解码链路 | `test_decode`(minimp3→audio_out FILE WAV) | MIPSTest.mp3 → 8.21s/48k/立体声/16bit PCM,WAV 合法(ch=2 rate=48000 frames=393984),**446× RT**(解码余量巨大) | +| 构建 | `build_ncm.sh` mipsel | **0 warning**(自有代码 -Wall -Wextra);text 764KB | + +**宿主截图**:`app/out/shots/{login,loginlive,home,playlist,player,lands,settings,mipsel_qemu}.png`(PPM→PNG)。 +注:宿主 5×7 点阵字仅 ASCII(中文/歌名在设备上以 gbk 大字库渲染;桌面截图为 ASCII label 布局自查)。 + +### 10.6 md5 + +``` +1e331964b62b6f5b0a593c5d97a4e030 ncm # mipsel 静态 App +(app 源码 md5) +50dc58dc62a9d71a23e90bb00c79a36b app/app.c cf5e3283065dfce798d668c22ee87f65 app/ncm_app_internal.h +b961bc769951208a09017f278c05a95b app/ui/login.c 5098bd843f6943bcda5aacfb7fae961f app/ui/home.c +ef600c9d3a88eb2463d1c8f312a5a202 app/ui/playlist.c a55846822deb0246c6780d8a7ac99f46 app/ui/player.c +05fef4b6fa5692abf0da57db40ad6f90 app/ui/settings.c 013111dea3172fdeb5353a2ec69a71a5 app/ui/chrome.c +203a9932bde8a739b436e246ba634638 app/logic/appstate.c 48a92829ac414ffad5dbe27e0836c21b app/logic/lyric.c +ed2d9658bc8707a5fd689b386da472f9 app/logic/cover.c e7cef918d2be04f6d4c5a25f62d2a9ab app/play/player_manager.c +4ceeb2f4f1b0a312c9468b9f77886dc7 app/play/audio_out.c 8c1716ca99cd773a0cf9a105f793b845 app/play/ipc_media.c +6ca4b2137c4a424d823f94a8b3fcd037 app/play/minimp3_impl.c a2ca48b2c6afa6c36f045ba8b2687fc5 app/test/test_ncm_app.c +9ebfb5401c7818cfd0c21d640b07c521 app/test/test_decode.c +``` + +### 10.7 出入清单 + +``` +新增(仅 patch_v2/ncm_app/): + app.json # 应用描述 + build_ncm.sh / build_ncm_host.sh # mipsel / 宿主构建 + app/app.c ncm_app_internal.h + app/ui/{chrome,login,home,playlist,player,settings}.c + app/logic/{appstate,lyric,cover}.c + app/play/{player_manager,audio_out,ipc_media,minimp3_impl}.c + app/test/{test_ncm_app,test_decode}.c + out/shots/*.png # 验证截图 +修改: + REPORT.md # 追加本批记录 +(未动 sdk/、meow_launcherd/、vendor/、core/;核心库 §9 原样使用) +``` + +### 10.8 真机待验证项 + +1. **ALSA 直开 `hw:0,0`**:本层用内核 UAPI(`/dev/snd/pcmC0D0p` + ioctl hw_params:RW_Interleaved/S16_LE/44100/2ch)——**设备时序/权限/采样率匹配**待真机验证;宿主已回退 FILE sink 证解码链路。 +2. **真实扫码登录(803)**:set-cookie 捕获→加密落盘→`login_status` 校验链路按文档实现但**未真扫**;建议真机扫码验证一次 `MUSIC_U` 内容与 home 拉歌单闭环。 +3. **媒体会话**:需 launcher 在真机跑(本 App 首真实媒体源);验证通知中心媒体卡片/Now Playing 显示 MSG_META + 下发 MSG_PLAYCTL 生效(本 App 已记 `ipc: no launcher socket` 为优雅降级,真机有 socket 即上报)。 +4. **大字库**:app.json `fonts:gbk` 声明已加;真机验证歌名/歌词中文渲染(宿主 ASCII 5×7 不含中文)。 + +### 10.9 诚实出入 / 已知限制 + +- **封面为歌单封面**(`playlist_detail` 的 coverImgUrl)而非**逐歌曲专辑封面**:`ncm_song_t` 未含 `al.picUrl`,为不改 §9 核心库(已驯化契约)故 v1 用歌单封面代为 artwork;逐曲封面需给 `ncm_song_t` 增 `al.picUrl` 字段(附加、向后兼容,建议二期)。 +- **歌词为同步加载**:播放页 enter 时 `player_manager_load_lyric` 同步网络拉取(UI 短暂阻塞);建议后续改后台线程+就绪标志(音频线程独立不受影响)。 +- **歌单曲目截断**:`NCM_SONG_MAX=256`(大歌单显示前 256 首);歌单数 `NCM_PLAYLIST_MAX=120`。 +- **launcher 媒体卡片封面**:经 MSG_META `artwork` 传 `/cover_current.jpg` 路径;若 launcher 读不到该路径则卡片仅题/艺/进度(App 内封面始终有)。 +- **IPC 仅处理 MSG_META/MSG_PLAYCTL**(未接 MSG_SYS,媒体会话最小闭环,符合 v1)。 +- **bss≈924KB / text≈764KB**:mipsel 静态;14MB 内存富余,但 bss 含封面下载缓冲(288KB)+QR 矩阵(31KB)+mbedtls 静态,已留充足余量。 +- **`meow_snack`/页脚**为 App 自绘(非 SDK 全局通知,避免与 launcher 系统 UI 冲突)。 diff --git a/patch_v2/ncm_app/app.json b/patch_v2/ncm_app/app.json new file mode 100644 index 0000000..3e93bce --- /dev/null +++ b/patch_v2/ncm_app/app.json @@ -0,0 +1 @@ +{"name":"网易云音乐","exec":"/usr/data/mnt/sd_0/MeowAPPs/os.meow.ncm/ncm","package":"os.meow.ncm","version":"0.1","version_code":1,"framework":1,"immersive":1,"fonts":"gbk","author":"MeowOS"} diff --git a/patch_v2/ncm_app/app/app.c b/patch_v2/ncm_app/app/app.c new file mode 100644 index 0000000..2bdde6e --- /dev/null +++ b/patch_v2/ncm_app/app/app.c @@ -0,0 +1,133 @@ +/* app.c — os.meow.ncm 应用装配层:main + 组装 + 事件分发。 + * + * 职责(对齐 hello_app/app.c):main 只做初始化/建页/跑事件循环/清理;把 meow_ui_run 的平铺输入 + * 回调(key/tap/swipe/tick/touch)路由到当前活动页。页面切换统一走 meow_view_page_push/pop/replace。 + * 播放线程(player_manager)+ 媒体会话 IPC 线程(ipc_media)在 main 起动、退出时停。 + * + * 铁律(APPS.md §3.4.1):物理键禁映射——on_key 不处理任何键语义;返回=左缘左滑/返回钮/request_exit。 + * 左缘返回:播放页→歌单→主页→退出(登录页→退出)。 + */ +#include "ncm_app_internal.h" +#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; + +static meow_view *g_login_view, *g_home_view, *g_pl_view, *g_plr_view, *g_set_view; + +static void on_sigterm(int s) { (void)s; meow_ui_request_exit(); } + +/* ============================================================ */ +/* 事件分发:meow_ui_run 平铺回调 → 当前页 */ +/* ============================================================ */ + +static void app_on_key(int keycode, int is_down) +{ + (void)keycode; (void)is_down; /* 物理键系统语义;App 不映射(§3.4.1) */ +} + +static void app_on_tap(int x, int y) +{ + if (login_is_active()) { login_on_tap(x, y); return; } + if (player_is_active()) { player_on_tap(x, y); return; } + if (settings_is_active()) { settings_on_tap(x, y); return; } + if (playlist_is_active()) { playlist_on_tap(x, y); return; } + home_on_tap(x, y); +} + +static void app_on_swipe(int dir, int dx, int dy) +{ + (void)dx; + if (dir == MEOW_SWIPE_LEFT) { + if (player_is_active()) { meow_view_page_pop(); return; } + if (settings_is_active()) { meow_view_page_pop(); return; } + if (playlist_is_active()) { meow_view_page_pop(); return; } + /* home / login = 根 → 退出 */ + meow_log(MEOW_LOG_INFO, "exit:swipe"); + meow_ui_request_exit(); + return; + } + if (home_is_active()) { home_swipe(dir, dy); return; } + if (playlist_is_active()) { playlist_swipe(dir, dy); return; } + if (player_is_active()) { player_on_swipe(dir, dy); return; } +} + +static void app_on_touch_down(int x, int y) +{ + if (home_is_active()) home_touch_down(x, y); + else if (playlist_is_active()) playlist_touch_down(x, y); + else if (settings_is_active()) settings_touch_down(x, y); +} + +static void app_on_touch_move(int x, int y) +{ + if (home_is_active()) home_touch_move(x, y); + else if (playlist_is_active()) playlist_touch_move(x, y); + else if (settings_is_active()) settings_touch_move(x, y); +} + +static void app_on_swipe_progress(int x_start, int dx) +{ + meow_ui_swipe_hint(x_start + dx); +} + +static void app_on_tick(void) +{ + uint64_t now = meow_ui_time_ms(); + if (login_is_active()) { login_tick(now); return; } + if (player_is_active()) { player_tick(now); return; } + chrome_tick(); +} + +/* ============================================================ */ +/* main */ +/* ============================================================ */ +int main(void) +{ + meow_log(MEOW_LOG_INFO, "start"); + meow_ui_opts o = { APP_W, APP_H }; + if (meow_ui_init(&o) != 0) { meow_log(MEOW_LOG_ERROR, "init failed"); return 1; } + meow_theme_add_listener(NULL); + signal(SIGTERM, on_sigterm); + + /* 应用数据/核心库上下文(cookie/deviceId/cache/设置) */ + appstate_init(); + + /* 播放 + 媒体会话线程(UI 不卡;无 launcher socket 时 ipc 优雅跳过) */ + player_manager_start(); + ipc_media_start(); + g_net_ready = 1; + + /* 建五页 view */ + g_login_view = login_view_create(); + g_home_view = home_view_create(); + g_pl_view = playlist_view_create(); + g_plr_view = player_view_create(); + g_set_view = settings_view_create(); + + /* 进入根页:已登录→home,否则→login */ + meow_view_page_push(g_st.logged_in ? ACT_HOME : ACT_LOGIN); + + meow_ui_events ev = { + app_on_key, app_on_tap, app_on_swipe, app_on_tick, app_on_swipe_progress, + app_on_touch_down, app_on_touch_move, + }; + meow_ui_run(&ev); + + /* 清理:停线程 → 释放页 */ + g_app_quit = 1; + player_manager_stop_thread(); + ipc_media_stop(); + meow_view_free(g_set_view); + meow_view_free(g_plr_view); + meow_view_free(g_pl_view); + meow_view_free(g_home_view); + meow_view_free(g_login_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 new file mode 100644 index 0000000..e255dac --- /dev/null +++ b/patch_v2/ncm_app/app/logic/appstate.c @@ -0,0 +1,165 @@ +/* logic/appstate.c — 应用全局状态:ctx/登录/歌单/歌曲/设置。仅依赖核心库 + SDK meow_config_*。 */ +#include "../ncm_app_internal.h" +#include +#include +#include + +#define NCM_DEFAULT_DATA "/usr/data/mnt/sd_0/MeowData/os.meow.ncm/data" + +int appstate_init(void) +{ + const char *dd = getenv("MEOW_DATA_DIR"); + if (!dd || !dd[0]) dd = NCM_DEFAULT_DATA; + strncpy(g_st.data_dir, dd, sizeof g_st.data_dir - 1); + g_st.data_dir[sizeof g_st.data_dir - 1] = 0; + + 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); + else + ncm_ctx_default_cache_dir(g_st.data_dir, g_st.cache_dir, sizeof g_st.cache_dir); + + appstate_load_settings(); + g_st.logged_in = (g_st.ctx.cookie[0] != 0); + g_st.active_playlist = -1; + g_st.playing_song = -1; + g_st.qr_size = 0; + g_st.login_state = NCM_LOGIN_PENDING; + + /* uid/nickname 从配置恢复(登录时落盘);uid 缺失时不在此阻塞校验——由 home_reload 拉歌单时 + * appstate_ensure_uid 触发 login_status_check(启动快,避免首屏长时间网络阻塞)。 */ + meow_config_get("ncm.uid", g_st.uid, sizeof g_st.uid); + meow_config_get("ncm.nickname", g_st.nickname, sizeof g_st.nickname); + + return 0; +} + +void appstate_load_settings(void) +{ + g_st.cache_limit_gb = meow_config_int("ncm.cache_limit_gb", 10); + if (g_st.cache_limit_gb < 1) g_st.cache_limit_gb = 1; + if (g_st.cache_limit_gb > 100) g_st.cache_limit_gb = 100; + + g_st.level = meow_config_int("ncm.level", NCM_LEVEL_HIRES); + if (g_st.level < NCM_LEVEL_STANDARD || g_st.level > NCM_LEVEL_HIRES) + g_st.level = NCM_LEVEL_HIRES; + + ncm_cache_set_limit_gb(&g_st.ctx.cache, g_st.cache_limit_gb); + g_st.ctx.default_level = g_st.level; +} + +void appstate_save_settings(void) +{ + char b[16]; + snprintf(b, sizeof b, "%d", g_st.cache_limit_gb); + meow_config_set("ncm.cache_limit_gb", b); + snprintf(b, sizeof b, "%d", g_st.level); + meow_config_set("ncm.level", b); + ncm_cache_set_limit_gb(&g_st.ctx.cache, g_st.cache_limit_gb); + g_st.ctx.default_level = g_st.level; +} + +void appstate_set_logged_in(const char *uid, const char *nickname) +{ + if (uid && uid[0]) { ncm_strlcpy(g_st.uid, uid, sizeof g_st.uid); meow_config_set("ncm.uid", uid); } + if (nickname && nickname[0]) { + ncm_strlcpy(g_st.nickname, nickname, sizeof g_st.nickname); + meow_config_set("ncm.nickname", nickname); + } + g_st.logged_in = 1; +} + +void appstate_logout(void) +{ + ncm_ctx_clear_cookie(&g_st.ctx); + g_st.logged_in = 0; + g_st.uid[0] = 0; + g_st.nickname[0] = 0; + g_st.active_playlist = -1; + g_st.playing_song = -1; + g_st.song_count = 0; + meow_config_remove("ncm.uid"); + meow_config_remove("ncm.nickname"); + cover_cache_reset(); +} + +/* 若 uid 缺失(cookie 在但没存 uid),用 login_status_check 校验并取回。返回 0=成功。 */ +int appstate_ensure_uid(void) +{ + if (!g_st.logged_in || !g_st.ctx.cookie[0]) return NCM_ERR_AUTH; + if (g_st.uid[0]) return NCM_OK; + char uid[32], nick[128]; + int rc = ncm_login_status_check(g_st.ctx.device_id, g_st.ctx.cookie, uid, sizeof uid, + nick, sizeof nick); + if (rc == NCM_OK) { + appstate_set_logged_in(uid, nick); + return NCM_OK; + } + /* 会话失效:清 cookie → 未登录 */ + if (rc == NCM_ERR_AUTH) { + ncm_ctx_clear_cookie(&g_st.ctx); + g_st.logged_in = 0; + } + return rc; +} + +int appstate_load_playlists(void) +{ + if (!g_st.logged_in) return NCM_ERR_AUTH; + int rc = appstate_ensure_uid(); + if (rc != NCM_OK) return rc; + memset(g_st.playlists, 0, sizeof g_st.playlists); + int n = 0; + rc = ncm_api_user_playlist(g_st.ctx.device_id, g_st.ctx.cookie, g_st.uid, + NCM_PLAYLIST_MAX, 0, g_st.playlists, NCM_PLAYLIST_MAX, &n); + g_st.playlist_count = (rc == NCM_OK) ? n : 0; + return rc; +} + +int appstate_load_playlist_songs(int idx) +{ + if (idx < 0 || idx >= g_st.playlist_count) return NCM_ERR_ARG; + g_st.active_playlist = idx; + const ncm_playlist_t *pl = &g_st.playlists[idx]; + + int idn = 0; + int rc = ncm_api_playlist_detail(g_st.ctx.device_id, g_st.ctx.cookie, pl->id, + NULL, 0, NULL, 0, g_st.song_ids, NCM_SONG_MAX, &idn); + if (rc != NCM_OK) { g_st.song_count = 0; return rc; } + + memset(g_st.songs, 0, sizeof g_st.songs); + int outn = 0; + for (int off = 0; off < idn; off += NCM_SONG_BATCH) { + int batch = idn - off; + if (batch > NCM_SONG_BATCH) batch = NCM_SONG_BATCH; + int bn = 0; + rc = ncm_api_song_detail(g_st.ctx.device_id, g_st.ctx.cookie, &g_st.song_ids[off], + batch, &g_st.songs[off], NCM_SONG_MAX - off, &bn); + if (rc != NCM_OK) break; + outn += bn; + if (bn < batch) break; + } + g_st.song_count = outn; + appstate_apply_playlist_cover(idx); + return NCM_OK; +} + +void appstate_apply_playlist_cover(int idx) +{ + if (idx < 0 || idx >= g_st.playlist_count) return; + ncm_strlcpy(g_st.cover_url, g_st.playlists[idx].cover, sizeof g_st.cover_url); +} + +/* 设置播放页当前歌曲 + 封面 URL(player 页 UI 读)。idx=-1 表示清除。 */ +void appstate_set_playing_song(int idx, const char *cover_url) +{ + g_st.playing_song = idx; + if (cover_url && cover_url[0]) + ncm_strlcpy(g_st.cover_url, cover_url, sizeof g_st.cover_url); +} + +int appstate_current_playing(void) +{ + if (g_st.playing_song < 0 || g_st.playing_song >= g_st.song_count) return -1; + return g_st.playing_song; +} diff --git a/patch_v2/ncm_app/app/logic/cover.c b/patch_v2/ncm_app/app/logic/cover.c new file mode 100644 index 0000000..d116046 --- /dev/null +++ b/patch_v2/ncm_app/app/logic/cover.c @@ -0,0 +1,141 @@ +/* logic/cover.c — 封面下载(HTTPS)+ 限幅 + 解码缓存(单张,URL 变更即换;后台线程)。 + * + * 内存纪律:下载限幅 NCM_COVER_MAX(~288KB);解码后的 RGBA 保持单张(换封面先 free 旧再解码)。 + * 线程模型:一个持久的 cover 工作线程负责「目标 URL → 下载 → 解码 → 换入」;UI 线程只经 + * cover_request(url)(提交目标)与 cover_get()/cover_changed()(读取/判变)+ cover_ack()(消费)。 + * 工作线程不碰 meow_ui(绘制在 UI 线程 tick 判 cover_changed 后触发 invalidate)。 + */ +#include "../ncm_app_internal.h" +#include +#include +#include +#include + +static pthread_mutex_t g_cm = PTHREAD_MUTEX_INITIALIZER; +static meow_img *g_cover_img; +static char g_cover_url[512]; /* g_cover_img 对应的 URL */ +static char g_tgt_url[512]; /* 目标 URL(worker 拉取) */ +static pthread_t g_thr; +static int g_thr_on; +static int g_quit; +static volatile int g_gen; /* 每次换入 +1 */ +static int g_acked; /* UI 已消费的版本 */ +static char g_cover_path[512]; /* 封面原始文件路径(供 launcher) */ + +static unsigned char g_buf[NCM_COVER_MAX]; /* 单张下载缓冲(常驻) */ + +static meow_img *decode_cover(const char *url, size_t *out_len) +{ + if (!url || !url[0]) return NULL; + ncm_http_req_t req; + memset(&req, 0, sizeof req); + req.url = url; + req.method = "GET"; + req.extra_hdrs = "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; + req.verify_ca = NCM_HTTP_VERIFY_DEFAULT; + req.timeout_ms = 10000; + ncm_http_resp_t resp; + int rc = ncm_http_request(&req, (char *)g_buf, sizeof g_buf, &resp); + if (rc != NCM_OK || resp.status != 200 || resp.body_len == 0) return NULL; + if (out_len) *out_len = resp.body_len; + return meow_img_load_mem((const void *)resp.body, (int)resp.body_len); +} + +static void save_cover_raw(const char *url, size_t len) +{ + if (!url || len == 0) return; + if (g_st.cache_dir[0]) { + char path[512]; + snprintf(path, sizeof path, "%s/cover_current.jpg", g_st.cache_dir); + ncm_store_write_file(path, g_buf, len); + pthread_mutex_lock(&g_cm); + ncm_strlcpy(g_cover_path, path, sizeof g_cover_path); + pthread_mutex_unlock(&g_cm); + } + (void)url; +} + +static void *cover_worker(void *arg) +{ + (void)arg; + while (!g_quit) { + pthread_mutex_lock(&g_cm); + if (!g_tgt_url[0]) { pthread_mutex_unlock(&g_cm); usleep(250 * 1000); continue; } + char url[512]; + ncm_strlcpy(url, g_tgt_url, sizeof url); + pthread_mutex_unlock(&g_cm); + + size_t blen = 0; + meow_img *img = decode_cover(url, &blen); + if (!img) { usleep(500 * 1000); continue; } + + pthread_mutex_lock(&g_cm); + /* 目标仍是 url 才换入(期间未提交更早) */ + if (g_tgt_url[0] && strcmp(g_tgt_url, url) == 0) { + if (g_cover_img) meow_img_free(g_cover_img); + g_cover_img = img; + ncm_strlcpy(g_cover_url, url, sizeof g_cover_url); + save_cover_raw(url, blen); + g_gen++; + } else { + meow_img_free(img); + } + pthread_mutex_unlock(&g_cm); + } + return NULL; +} + +/* UI 线程:提交目标封面 URL(无网络/无 URL 则忽略)。 */ +void cover_request(const char *url) +{ + if (!url || !url[0]) return; + pthread_mutex_lock(&g_cm); + if (g_cover_url[0] && strcmp(g_cover_url, url) == 0) { pthread_mutex_unlock(&g_cm); return; } + ncm_strlcpy(g_tgt_url, url, sizeof g_tgt_url); + pthread_mutex_unlock(&g_cm); + if (!g_thr_on) { + g_thr_on = 1; + if (pthread_create(&g_thr, NULL, cover_worker, NULL) == 0) + pthread_detach(g_thr); + else + g_thr_on = 0; + } +} + +/* UI 线程:读取当前封面(不拥有)。 */ +meow_img *cover_get(void) +{ + meow_img *im; + pthread_mutex_lock(&g_cm); + im = g_cover_img; + pthread_mutex_unlock(&g_cm); + return im; +} + +/* 是否有新封面待绘制(UI tick 判变)。 */ +int cover_changed(void) { return g_gen != g_acked; } +/* UI 消费一次:标记已见当前版本。 */ +void cover_ack(void) { g_acked = g_gen; } + +/* 封面原始文件路径(供 launcher 媒体卡片读);NULL=无。 */ +const char *cover_current_path(void) +{ + const char *p; + pthread_mutex_lock(&g_cm); + p = g_cover_path[0] ? g_cover_path : NULL; + pthread_mutex_unlock(&g_cm); + return p; +} + +void cover_cache_reset(void) +{ + pthread_mutex_lock(&g_cm); + if (g_cover_img) { meow_img_free(g_cover_img); g_cover_img = NULL; } + g_cover_url[0] = 0; + g_tgt_url[0] = 0; + g_cover_path[0] = 0; + pthread_mutex_unlock(&g_cm); + g_gen++; + g_acked = g_gen; +} diff --git a/patch_v2/ncm_app/app/logic/lyric.c b/patch_v2/ncm_app/app/logic/lyric.c new file mode 100644 index 0000000..11e1a49 --- /dev/null +++ b/patch_v2/ncm_app/app/logic/lyric.c @@ -0,0 +1,139 @@ +/* logic/lyric.c — LRC 时间轴解析 + 当前行定位。 + * + * 输入:ncm_api_lyric 返回的 lrc(原文)与 tlrc(翻译)。两者均为标准 [mm:ss.xx] 行。 + * 解析:把所有带时间戳的行按时间升序排序,存 timestamp(ms)/text;翻译按相同时间戳合并到 + * 对应原文行(存 tl)。无匹配的翻译行忽略。流式、有界:NCM_LYRIC_LINES 行,超限截断。 + * 纯 C 无 UI,仅依赖标准库。 + */ +#include "../ncm_app_internal.h" +#include +#include +#include + +#define LRC_MAX_TEXT 192 +static long s_t[NCM_LYRIC_LINES]; +static char s_txt[NCM_LYRIC_LINES][LRC_MAX_TEXT]; +static char s_tl[NCM_LYRIC_LINES][LRC_MAX_TEXT]; +static int s_n; + +static void lrc_parse(const char *lrc, int is_trans) +{ + if (!lrc) return; + const char *p = lrc; + while (*p) { + /* 找行首 '[' */ + while (*p && *p != '[') p++; + if (!*p) break; + /* 解析一个或多个时间标签 */ + int have = 0; + long first_ms = 0; + while (*p == '[') { + int m = 0, s = 0, cs = 0; + if (sscanf(p, "[%d:%d.%d]", &m, &s, &cs) >= 2) { + long ms = (long)m * 60000L + (long)s * 1000L + (long)cs * 10L; + if (!have) { first_ms = ms; have = 1; } + /* 跳到本标签结尾 */ + const char *q = strchr(p, ']'); + if (!q) break; + p = q + 1; + } else { + /* 非时间标签([ti:..] 等)——跳过到行尾 */ + const char *q = strchr(p, ']'); + if (!q) return; + p = q + 1; + /* 若行内还有时间标签继续解析(极少),否则整行的正文以第一个文本为准 */ + break; + } + } + if (have) { + /* 正文 = 到行尾(去 \r\n) */ + const char *e = p; + while (*e && *e != '\n' && *e != '\r') e++; + size_t len = (size_t)(e - p); + if (len > LRC_MAX_TEXT - 1) len = LRC_MAX_TEXT - 1; + if (is_trans) { + /* 翻译行:按 first_ms 匹配到已有原文行 */ + for (int i = 0; i < s_n; i++) { + if (s_t[i] == first_ms) { + memcpy(s_tl[i], p, len); s_tl[i][len] = 0; + break; + } + } + } else { + if (s_n < NCM_LYRIC_LINES) { + s_t[s_n] = first_ms; + memcpy(s_txt[s_n], p, len); s_txt[s_n][len] = 0; + s_tl[s_n][0] = 0; + s_n++; + } + } + } + while (*p && *p != '\n') p++; /* 跳到下一行 */ + if (*p == '\n') p++; + } +} + +static int cmp_lines(const void *a, const void *b) +{ + const int *ia = (const int *)a, *ib = (const int *)b; + if (s_t[*ia] < s_t[*ib]) return -1; + if (s_t[*ia] > s_t[*ib]) return 1; + return 0; +} + +int lyric_parse(const char *lrc, const char *tlrc) +{ + lyric_clear(); + lrc_parse(lrc, 0); + lrc_parse(tlrc, 1); + /* 按时间排序(用索引数组) */ + int *idx = (int *)malloc(sizeof(int) * (size_t)(s_n ? s_n : 1)); + if (!idx) return s_n; + for (int i = 0; i < s_n; i++) idx[i] = i; + qsort(idx, (size_t)s_n, sizeof(int), cmp_lines); + long ttmp[NCM_LYRIC_LINES]; + char txt[NCM_LYRIC_LINES][LRC_MAX_TEXT]; + char tl[NCM_LYRIC_LINES][LRC_MAX_TEXT]; + memcpy(ttmp, s_t, sizeof(s_t)); + memcpy(txt, s_txt, sizeof(s_txt)); + memcpy(tl, s_tl, sizeof(s_tl)); + for (int i = 0; i < s_n; i++) { + s_t[i] = ttmp[idx[i]]; + memcpy(s_txt[i], txt[idx[i]], LRC_MAX_TEXT); + memcpy(s_tl[i], tl[idx[i]], LRC_MAX_TEXT); + } + free(idx); + return s_n; +} + +int lyric_line_at(int64_t ms) +{ + int line = 0; + for (int i = 0; i < s_n; i++) { + if (s_t[i] <= ms) line = i; + else break; + } + return line; +} + +const char *lyric_line_text(int i) +{ + if (i < 0 || i >= s_n) return ""; + return s_txt[i]; +} + +const char *lyric_line_tl(int i) +{ + if (i < 0 || i >= s_n) return ""; + return s_tl[i]; +} + +int lyric_count(void) { return s_n; } + +void lyric_clear(void) +{ + s_n = 0; + memset(s_t, 0, sizeof(s_t)); + memset(s_txt, 0, sizeof(s_txt)); + memset(s_tl, 0, sizeof(s_tl)); +} diff --git a/patch_v2/ncm_app/app/ncm_app_internal.h b/patch_v2/ncm_app/app/ncm_app_internal.h new file mode 100644 index 0000000..98b9e34 --- /dev/null +++ b/patch_v2/ncm_app/app/ncm_app_internal.h @@ -0,0 +1,259 @@ +/* ncm_app_internal.h — os.meow.ncm 应用内部共享头(UI 各页 + app 装配层 + 宿主测试面)。 + * + * 层级(对齐 hello_app/app.c 批② + files_app): + * - app.c main + 装配 + 事件分发(把 meow_ui_run 的平铺回调路由到各页) + * - app/ui/.c 每页一个 view 文件(login/home/playlist/player/settings) + * - app/ui/common/chrome.c 共享 chrome(topbar / 内容区几何 / 空态 / 提示) + * - app/logic/ appstate(全局 ctx/歌单/歌曲/播放态)+ lyric(LRC 解析) + cover(封面缓存) + * - app/play/ player_manager(播放线程) + audio_out(ALSA/文件输出) + ipc_media(媒体会话 IPC) + * + * 铁律(APPS.md §3.4.1 / WORK_STATE.md):物理键禁映射——on_key 不处理任何键语义; + * 返回=左缘左滑(on_swipe LEFT)/返回钮/meow_ui_request_exit;左缘返回:播放页→歌单→主页→退出。 + * 播放/网络/IPC 用独立线程,UI 不卡。媒体会话经 meow_ipc.h 帧格式最小客户端上报 + * MSG_META + 收 MSG_PLAYCTL(SDK 尚无媒体封装,App 内实现)。 + */ +#ifndef NCM_APP_INTERNAL_H +#define NCM_APP_INTERNAL_H + +#include +#include +#include +#include "meow_ui.h" +#include "meow_ui_view.h" +#include "ncm.h" /* 核心库伞头(ctx/api/login/cache/...) */ + +/* ---- Activity 归属(§6.1 ①)---- */ +#define ACT_LOGIN 0 /* 扫码登录页(根,未登录) */ +#define ACT_HOME 1 /* 我的歌单(根/主页,已登录) */ +#define ACT_PLAYLIST 2 /* 歌单详情(曲目列表) */ +#define ACT_PLAYER 3 /* 播放页(可横竖屏切,v1 允许切) */ +#define ACT_SETTINGS 4 /* 设置页 */ + +/* ---- 布局 ---- */ +#define APP_W 480 +#define APP_H 720 +#define APP_TOPBAR_H 56 +#define APP_ROW_H 56 /* 列表行高 */ +#define APP_PAD 16 +#define APP_OP_W 48 /* 行右侧操作区触达宽 */ +#define APP_FOOTER_H 26 + +/* ---- 数据规模(内存纪律:歌单/歌曲大列表虚拟化显示,数组有界)---- */ +#define NCM_PLAYLIST_MAX 120 /* 我的歌单上限 */ +#define NCM_SONG_MAX 256 /* 单个歌单曲目上限(大歌单截断前 256 条) */ +#define NCM_SONG_BATCH 64 /* song_detail 分批每批条数 */ +#define NCM_LYRIC_LINES 600 /* 歌词行上限 */ +#define NCM_COVER_MAX 288*1024 /* 封面下载限幅 ~288KB */ + +/* ---- 播放/媒体会话状态(play 线程 ↔ UI/IPC 共享,mu 保护)---- */ +enum { + NCM_PC_STOPPED = 0, + NCM_PC_PLAYING = 1, + NCM_PC_PAUSED = 2, + NCM_PC_BUFFER = 3, /* 缓冲/加载中 */ +}; + +/* 播放器命令(IPC MSG_PLAYCTL cmd 一一对应)*/ +enum { + NCM_CTL_PLAY = 1, + NCM_CTL_PAUSE = 2, + NCM_CTL_PREV = 3, + NCM_CTL_NEXT = 4, + NCM_CTL_SEEK = 5, +}; + +/* 跨线程播放运行时状态(main 只读展示;play 线程写,IPC 线程读)。 */ +typedef struct ncm_play_run { + pthread_mutex_t mu; + int state; /* NCM_PC_* */ + int cur_song; /* 当前曲目在 g_st.songs[] 下标;-1=无 */ + int64_t pos_ms; /* 当前播放位置(播放进程推进,seek 写此) */ + int64_t dur_ms; /* 当前曲目标称时长 */ + int cmd; /* 暂存最新内部命令(play/pause/prev/next/seek)*/ + int64_t cmd_seek_ms; /* cmd==SEEK 目标 */ + int seq; /* 播放代际(每次 start 递增,用于区分过期信号)*/ + int err; /* 最近一次播放错误(0=无) */ + char errmsg[96]; + int has_track; /* 当前是否有可播曲目(含 url 获取中) */ +} ncm_play_run_t; + +/* ---- 音质档位字符串(设置页 + 持久化用)---- */ +extern const char *media; /* 占位 */ + +/* ---- App 全局状态(main 线程拥有;play 线程经 ncm_play_run 共享)---- */ +typedef struct ncm_app_state { + ncm_ctx_t ctx; /* 核心库上下文(device_id/cookie/cache) */ + char data_dir[512]; /* 数据目录(MeowData/os.meow.ncm/data) */ + char cache_dir[512]; + + /* 登录 */ + char unikey[64]; + char qr_url[256]; + unsigned char qr_mods[177*177]; /* QR 位图矩阵(1=暗) */ + int qr_size; + int login_state; /* NCM_LOGIN_* */ + int logged_in; + + /* 账号 */ + char uid[32]; + char nickname[128]; + + /* 我的歌单 */ + ncm_playlist_t playlists[NCM_PLAYLIST_MAX]; + int playlist_count; + int active_playlist; /* 当前打开歌单的下标;-1=无 */ + + /* 当前歌单曲目 */ + long song_ids[NCM_SONG_MAX]; + ncm_song_t songs[NCM_SONG_MAX]; + int song_count; + + /* 播放页视图态 */ + int playing_song; /* 正在播放歌曲在 songs[] 下标(UI 侧缓存);-1=无 */ + meow_img *cover_img; /* 当前封面(解码后;UI 持有,play 线程不碰) */ + char cover_url[512]; /* 当前封面 URL(playlist/song cover) */ + + /* 歌词(播放器加载后由 play 线程填充,UI 读)*/ + pthread_mutex_t lyric_mu; + int lyric_line_count; + int cur_lyric_line; + + /* 设置(持久化 MeowData/os.meow.ncm/data)*/ + int level; /* NCM_LEVEL_* */ + int cache_limit_gb; +} ncm_app_state_t; + +extern ncm_app_state_t g_st; /* 全局状态(app.c 定义) */ +extern ncm_play_run_t g_play; /* 播放运行态(app.c 定义) */ +extern volatile int g_app_quit; /* 请求退出(线程停) */ +extern volatile int g_net_ready; /* 网络/核心库线程安全可用 */ +extern int g_host_mock; /* 1=宿主/测试:跳过网络,用预置数据 */ + +/* ---- 通用矩形 ---- */ +typedef struct { int x, y, w, h; } ncm_rect_t; +#define NR(x,y,w,h) ((ncm_rect_t){ (x),(y),(w),(h) }) + +/* ---- chrome(ui/common/chrome.c)---- */ +int ncm_statusbar(void); +ncm_rect_t ncm_topbar_rect(void); +ncm_rect_t ncm_content_rect(void); +meow_widget *ncm_topbar_create(const char *title, int back, const char *action_label); +void ncm_draw_empty(ncm_rect_t r, const char *kind, const char *what); +void ncm_draw_footer(ncm_rect_t r, const char *text); +void ncm_snack(const char *text); /* 底部 2s 提示条(非阻塞) */ +void chrome_tick(void); /* 周期检查 snack 到期清除 */ + +/* ---- 页面(ui/.c)---- */ +/* login */ +meow_view *login_view_create(void); +int login_is_active(void); +void login_on_tap(int x, int y); +void login_tick(uint64_t now); +int login_start(void); /* 触发 unikey 获取(宿主测试:无网络则返回) */ +const char *login_status_str(int state); + +/* home */ +meow_view *home_view_create(void); +int home_is_active(void); +int home_on_tap(int x, int y); +void home_swipe(int dir, int dist); +void home_touch_down(int x, int y); +void home_touch_move(int x, int y); +void home_reload(void); /* 刷新我的歌单 */ +int home_go_up(void); /* 左缘返回:到底则退出 */ + +/* playlist */ +meow_view *playlist_view_create(void); +int playlist_is_active(void); +int playlist_on_tap(int x, int y); +void playlist_swipe(int dir, int dist); +void playlist_touch_down(int x, int y); +void playlist_touch_move(int x, int y); +void playlist_open(int idx); /* 打开第 idx 个歌单(下拉进歌单详情) */ +void playlist_refresh(void); + +/* player */ +meow_view *player_view_create(void); +int player_is_active(void); +void player_on_tap(int x, int y); +void player_tick(uint64_t now); /* 播放进度/歌词滚动 */ +void player_play_song(int idx); /* 从歌单第 idx 首开始播放 */ +void player_pause_toggle(int on); +void player_next(int dir); /* dir=+1 下一首 / -1 上一首 */ +void player_seek(int64_t ms); +void player_on_swipe(int dir, int dist); /* 横屏切换/返回 */ +int player_set_screen(int landscape); /* 1=横屏 0=竖屏;返回之前 */ +int player_screen(void); + +/* settings */ +meow_view *settings_view_create(void); +int settings_is_active(void); +int settings_on_tap(int x, int y); +void settings_swipe(int dir, int dist); +void settings_touch_down(int x, int y); +void settings_touch_move(int x, int y); +void settings_open(void); /* 进设置页 */ +void settings_reload(void); + +/* ---- logic ---- */ +int appstate_init(void); /* 初始化 ctx/data/cookie/设置;0=成功 */ +void appstate_load_settings(void); +void appstate_save_settings(void); +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); /* 拉歌单详情+曲目详情 */ +void appstate_apply_playlist_cover(int idx); +void appstate_set_playing_song(int idx, const char *cover_url); +int appstate_current_playing(void); /* 返回当前播放歌曲下标(UI) */ + +/* lyric */ +int lyric_parse(const char *lrc, const char *tlrc); /* 填充 g_lyric_* */ +int lyric_line_at(int64_t ms); /* 当前行 */ +const char *lyric_line_text(int i); +const char *lyric_line_tl(int i); +int lyric_count(void); +void lyric_clear(void); + +/* cover */ +void cover_request(const char *url); /* 提交目标封面 URL(后台线程下载+解码) */ +meow_img *cover_get(void); /* 当前封面(不拥有;NULL=无) */ +int cover_changed(void); /* 1=有新封面待绘制(UI tick 判变) */ +void cover_ack(void); /* UI 消费一次(已见当前版本) */ +const char *cover_current_path(void); /* 封面原始文件路径(供 launcher 媒体卡片读);NULL=无 */ +void cover_cache_reset(void); + +/* ---- play(app/play/)---- */ +/* audio_out */ +int audio_out_open(int sample_rate, int channels, int bits); /* 0=成功 */ +int audio_out_write(const short *samples, int frames); /* <0=错误/结束 */ +void audio_out_close(void); +void audio_out_set_target(int ms); /* 预留:缓冲目标 */ +int audio_out_buffered_ms(void); + +/* player_manager:播放线程(下载+解码+输出)驱动 */ +int player_manager_start(void); /* 起播放线程;0=成功 */ +void player_manager_stop(void); /* 停播(清曲目/置 STOPPED) */ +void player_manager_stop_thread(void);/* 停线程(app 退出时 join) */ +void player_manager_play(int song_idx); /* 加载并播放 */ +void player_manager_pause(int on); /* on=1 暂停 */ +void player_manager_next(int dir); /* dir=+1/-1 */ +void player_manager_seek(int64_t ms); +void player_manager_set_quality(int level, int cache_gb); +int player_manager_state(void); /* NCM_PC_* */ +int player_manager_playing_song(void); +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); + +/* ipc_media:媒体会话上报/收指令 */ +int ipc_media_start(void); /* 起 IPC 线程;0=成功 */ +void ipc_media_stop(void); + +/* ---- 宿主测试面(app/test/test_ncm_app.c 驱动真实页面)---- */ +void app_host_set_mock(int mode); /* 0=无 1=home 2=playlist 3=player 4=settings 5=login */ +void app_host_paint_page(int page); /* 构建指定页并整帧绘制到 back_buf(供 dump)*/ + +#endif /* NCM_APP_INTERNAL_H */ diff --git a/patch_v2/ncm_app/app/play/audio_out.c b/patch_v2/ncm_app/app/play/audio_out.c new file mode 100644 index 0000000..640ace7 --- /dev/null +++ b/patch_v2/ncm_app/app/play/audio_out.c @@ -0,0 +1,166 @@ +/* play/audio_out.c — 音频输出抽象:ALSA 直开 hw:0,0(44.1k/16bit/立体声)或 FILE/NULL sink。 + * + * 设备(mipsel musl 无 libasound):用 ALSA 内核 UAPI(sound/asound.h)直开 /dev/snd/pcmC0D0p, + * 非 mmap RW 模式下 ioctl 设 hw_params + write() 写交织帧。布局/时序真机待验证(本层只保证跨编译 + + * 宿主回退正确)。 + * 宿主/qemu 验证:无 /dev/snd/pcmC0D0p → 回退 FILE sink(写 WAV,env NCM_AUDIO_OUT 指定路径, + * 缺省 /tmp/ncm_out.wav)或 NULL(env NCM_AUDIO_SINK=null,丢弃,仅测解码 RT 用)。 + * 线程模型:仅由 player_manager 播放线程调用(单写者);audio_out_close 可被 UI 线程调用打断阻塞写。 + */ +#include "../ncm_app_internal.h" +#include +#include +#include +#include +#include +#include + +#ifdef __linux__ +#include +#include +#define NCM_HAVE_ALSA 1 +#endif + +enum { SINK_ALSADEV = 0, SINK_FILE = 1, SINK_NULL = 2 }; +static int g_sink = -1; +static int g_fd = -1; /* alsa or file fd */ +static int g_rate, g_ch, g_bits; +static const char *g_path = NULL; + +#define NCM_DEFAULT_OUT "/tmp/ncm_out.wav" + +void audio_out_set_target(int ms) { (void)ms; } +int audio_out_buffered_ms(void) { return 0; } + +#ifdef NCM_HAVE_ALSA +/* ALSA 非 mmap 直装:开设备 → hw_params(access=rw_interleaved, S16_LE, rate, 2ch, s16)→ write。 + * 注意:musl 的 sound/asound.h 中 masks/intervals 是**重索引**数组: + * masks[k] ↔ hw_param (FIRST_MASK + k); intervals[k] ↔ hw_param (FIRST_INTERVAL + k)。 + * 故访问时须减 FIRST_MASK/FIRST_INTERVAL,否则越界/错位。 */ +static void pmask(struct snd_mask *m, unsigned bit) { m->bits[bit / 32] |= 1u << (bit % 32); } +static void pivset(struct snd_interval *iv, unsigned val) +{ + iv->min = iv->max = val; + iv->integer = 1; + iv->openmin = iv->openmax = 0; + iv->empty = 0; +} +static int alsa_open(int rate, int ch, int bits) +{ + if (bits != 16) return -1; + int fd = open("/dev/snd/pcmC0D0p", O_RDWR); + if (fd < 0) return -1; + struct snd_pcm_hw_params p; + memset(&p, 0, sizeof p); + p.flags = 0; + pmask(&p.masks[SNDRV_PCM_HW_PARAM_ACCESS - SNDRV_PCM_HW_PARAM_FIRST_MASK], + SNDRV_PCM_ACCESS_RW_INTERLEAVED); + pmask(&p.masks[SNDRV_PCM_HW_PARAM_FORMAT - SNDRV_PCM_HW_PARAM_FIRST_MASK], + SNDRV_PCM_FORMAT_S16_LE); + pmask(&p.masks[SNDRV_PCM_HW_PARAM_SUBFORMAT - SNDRV_PCM_HW_PARAM_FIRST_MASK], + SNDRV_PCM_SUBFORMAT_STD); + pivset(&p.intervals[SNDRV_PCM_HW_PARAM_CHANNELS - SNDRV_PCM_HW_PARAM_FIRST_INTERVAL], + (unsigned)ch); + pivset(&p.intervals[SNDRV_PCM_HW_PARAM_RATE - SNDRV_PCM_HW_PARAM_FIRST_INTERVAL], + (unsigned)rate); + p.rmask = ~0u; + p.info = 0; + p.msbits = 0; + p.rate_num = 0; + p.rate_den = 0; + if (ioctl(fd, SNDRV_PCM_IOCTL_HW_PARAMS, &p) != 0) { close(fd); return -1; } + ioctl(fd, SNDRV_PCM_IOCTL_PREPARE, 0); + return fd; +} +#endif + +/* 写 WAV 头(44 字节标准 PCM 布局:RIFF/WAVE/fmt(16)/data)。 + * fmt(16) = format(2) ch(2) sample_rate(4) byte_rate(4) block_align(2) bits(2)。 */ +static void file_write_header(int fd, int rate, int ch, int bits, uint32_t data_len) +{ + unsigned char h[44]; + memset(h, 0, sizeof h); + memcpy(h+0, "RIFF", 4); + uint32_t le = 36 + data_len; memcpy(h+4, &le, 4); + memcpy(h+8, "WAVE", 4); + memcpy(h+12, "fmt ", 4); + le = 16; memcpy(h+16, &le, 4); + uint16_t s16 = 1; memcpy(h+20, &s16, 2); /* PCM */ + s16 = (uint16_t)ch; memcpy(h+22, &s16, 2); + le = (uint32_t)rate; memcpy(h+24, &le, 4); /* sample rate */ + le = (uint32_t)(rate * ch * bits / 8); memcpy(h+28, &le, 4); /* byte rate */ + s16 = (uint16_t)(ch * bits / 8); memcpy(h+32, &s16, 2); /* block align */ + s16 = (uint16_t)bits; memcpy(h+34, &s16, 2); /* bits/sample */ + memcpy(h+36, "data", 4); + le = data_len; memcpy(h+40, &le, 4); + if (write(fd, h, 44) != 44) { /* 忽略 */ } +} + +int audio_out_open(int sample_rate, int channels, int bits) +{ + g_rate = sample_rate; g_ch = channels; g_bits = bits; + const char *env = getenv("NCM_AUDIO_SINK"); + g_path = getenv("NCM_AUDIO_OUT"); + if (!g_path || !g_path[0]) g_path = NCM_DEFAULT_OUT; + + /* 显式 null(测 RT) */ + if (env && strcmp(env, "null") == 0) { g_sink = SINK_NULL; g_fd = -1; return 0; } + + /* 尝试 ALSA(仅未显式指定 file;宿主无设备自动回退) */ + if (!(env && strcmp(env, "file") == 0)) { +#ifdef NCM_HAVE_ALSA + int fd = alsa_open(sample_rate, channels, bits); + if (fd >= 0) { g_sink = SINK_ALSADEV; g_fd = fd; return 0; } +#endif + } + /* FILE sink */ + int fd = open(g_path, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0) { g_sink = SINK_NULL; g_fd = -1; return 0; } /* 无路可写 → null */ + file_write_header(fd, sample_rate, channels, bits, 0); + g_sink = SINK_FILE; + g_fd = fd; + return 0; +} + +int audio_out_write(const short *samples, int frames) +{ + if (g_app_quit) return -1; + if (g_sink == SINK_NULL || g_sink < 0) return frames; + size_t n = (size_t)frames * (size_t)g_ch * (size_t)(g_bits / 8); + if (g_sink == SINK_ALSADEV) { + /* 阻塞尽力写满 - EINTR/EAGAIN 重试;错误返回 */ + const unsigned char *p = (const unsigned char *)samples; + size_t off = 0; + while (off < n) { + ssize_t w = write(g_fd, p + off, n - off); + if (w > 0) { off += (size_t)w; continue; } + if (w < 0 && (errno == EINTR || errno == EAGAIN)) continue; + return -1; + } + return frames; + } + if (g_sink == SINK_FILE) { + if (write(g_fd, samples, n) != (ssize_t)n) return -1; + return frames; + } + return frames; +} + +void audio_out_close(void) +{ + if (g_sink == SINK_FILE && g_fd >= 0) { + /* 回填:offset 4 = RIFF 总长-36;offset 40 = data 长度 */ + off_t end = lseek(g_fd, 0, SEEK_END); + if (end > 44) { + uint32_t data_len = (uint32_t)(end - 44); + lseek(g_fd, 4, SEEK_SET); + uint32_t le = 36 + data_len; + if (write(g_fd, &le, 4) != 4) { } + lseek(g_fd, 40, SEEK_SET); + if (write(g_fd, &data_len, 4) != 4) { } + } + } + if (g_fd >= 0) close(g_fd); + g_fd = -1; + g_sink = -1; +} diff --git a/patch_v2/ncm_app/app/play/ipc_media.c b/patch_v2/ncm_app/app/play/ipc_media.c new file mode 100644 index 0000000..58e289c --- /dev/null +++ b/patch_v2/ncm_app/app/play/ipc_media.c @@ -0,0 +1,212 @@ +/* play/ipc_media.c — 媒体会话 IPC 最小客户端(SDK 尚无媒体封装,App 内用 meow_ipc.h 帧格式直连)。 + * + * 连接:AF_UNIX SOCK_STREAM 到 MEOW_IPC_SOCK(缺省 /usr/data/meow_ipc.sock)。HELLO(app_id, + * MF_PLAYBACK) 注册为媒体源;之后**同一连接**周期发 MSG_META(~1s,进度随播放推进)并阻塞等 + * launcher 下发 MSG_PLAYCTL(播放/暂停/上下曲/seek)。launcher 经该连接 fd 回发(meow_media.c + * send_playctl 用最近活跃会话的 fd)。无 socket/launcher 不跑 → 优雅跳过(打日志不崩)。 + * 线程模型:独立线程(main 起了 meow_ui_run 阻塞;播放线程归 player_manager,本线程只做 IPC 收发)。 + */ +#include "../ncm_app_internal.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "meow_ipc.h" + +static pthread_t g_thr; +static int g_thr_on; +static int g_quit; + +static const char *sock_path(void) +{ + const char *e = getenv("MEOW_IPC_SOCK"); + return (e && e[0]) ? e : MEOW_IPC_PATH; +} + +static int connect_path(const char *path) +{ + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return -1; + struct sockaddr_un a; + memset(&a, 0, sizeof a); + a.sun_family = AF_UNIX; + strncpy(a.sun_path, path, sizeof a.sun_path - 1); + if (connect(fd, (struct sockaddr *)&a, sizeof a) < 0) { close(fd); return -1; } + return fd; +} + +/* 发送一帧(头+负载)。 */ +static int send_frame(int fd, uint16_t type, uint32_t req_id, const void *payload, uint32_t len) +{ + uint8_t hdr[MEOW_IPC_HDR_LEN]; + uint32_t magic = MEOW_IPC_MAGIC; + uint16_t ver = MEOW_IPC_VERSION; + memcpy(hdr + 0, &magic, 4); + memcpy(hdr + 4, &ver, 2); + memcpy(hdr + 6, &type, 2); + memcpy(hdr + 8, &req_id, 4); + memcpy(hdr + 12, &len, 4); + size_t off = 0; + while (off < sizeof hdr) { + ssize_t w = write(fd, hdr + off, sizeof hdr - off); + if (w <= 0) return -1; + off += (size_t)w; + } + if (len && payload) { + off = 0; + while (off < len) { + ssize_t w = write(fd, (const uint8_t *)payload + off, len - off); + if (w <= 0) return -1; + off += (size_t)w; + } + } + return 0; +} + +static int read_frame(int fd, uint16_t *type, uint8_t *rbuf, uint32_t rcap) +{ + uint8_t hdr[MEOW_IPC_HDR_LEN]; + size_t off = 0; + while (off < sizeof hdr) { + ssize_t r = read(fd, hdr + off, sizeof hdr - off); + if (r <= 0) return -1; + off += (size_t)r; + } + uint32_t magic, len; uint16_t ver; + memcpy(&magic, hdr, 4); memcpy(&ver, hdr + 4, 2); memcpy(type, hdr + 6, 2); + memcpy(&len, hdr + 12, 4); + if (magic != MEOW_IPC_MAGIC || ver != MEOW_IPC_VERSION) return -1; + if (len > rcap) len = rcap; + off = 0; + while (off < len) { + ssize_t r = read(fd, rbuf + off, len - off); + if (r <= 0) return -1; + off += (size_t)r; + } + return (int)len; +} + +/* 组并发送一条 META。 */ +static void send_meta(int fd, uint32_t req_id) +{ + m_meta_t mm; + memset(&mm, 0, sizeof mm); + ncm_strlcpy(mm.app_id, "os.meow.ncm", sizeof mm.app_id); + + int idx = player_manager_playing_song(); + pthread_mutex_lock(&g_play.mu); + int pos = (int)g_play.pos_ms; + int dur = (int)g_play.dur_ms; + int state = g_play.state; + int err = g_play.err; + pthread_mutex_unlock(&g_play.mu); + + 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); + } + snprintf(mm.title, sizeof mm.title, "%s", title); + snprintf(mm.artist, sizeof mm.artist, "%s", artist); + snprintf(mm.album, sizeof mm.album, "%s", album); + mm.pos_ms = (uint32_t)(pos < 0 ? 0 : pos); + mm.dur_ms = (uint32_t)(dur < 0 ? 0 : dur); + uint32_t st = 0; + if (err != 0) st = 0; + else if (state == NCM_PC_PLAYING) st = 1; + else if (state == NCM_PC_PAUSED) st = 2; + else if (state == NCM_PC_BUFFER) st = 3; + else st = 0; + mm.state = st; + /* 封面:若已落盘(cover 缓存文件)则提供路径 */ + mm.flags = 0; + const char *cp = cover_current_path(); + if (cp && cp[0]) { + snprintf(mm.artwork, sizeof mm.artwork, "%s", cp); + mm.flags |= MF_ART | MF_COVER; + } + if (lyric_count() > 0) mm.flags |= MF_LYRIC; + send_frame(fd, MSG_META, req_id, &mm, sizeof mm); +} + +static void apply_playctl(const m_playctl_t *c) +{ + switch (c->cmd) { + case NCM_CTL_PLAY: player_manager_pause(0); break; + case NCM_CTL_PAUSE: player_manager_pause(1); break; + case NCM_CTL_PREV: player_manager_next(-1); break; + case NCM_CTL_NEXT: player_manager_next(1); break; + case NCM_CTL_SEEK: player_manager_seek(c->seek_ms); break; + default: break; + } +} + +static void *ipc_thread(void *arg) +{ + (void)arg; + int fd = connect_path(sock_path()); + if (fd < 0) { meow_log(MEOW_LOG_WARN, "ipc: no launcher socket"); return NULL; } + + m_hello_t h; + memset(&h, 0, sizeof h); + ncm_strlcpy(h.app_id, "os.meow.ncm", sizeof h.app_id); + h.version = 1; + h.flags = MF_PLAYBACK; + if (send_frame(fd, MSG_HELLO, 1, &h, sizeof h) != 0) { close(fd); return NULL; } + uint8_t hb[256]; + uint16_t htype; + if (read_frame(fd, &htype, hb, sizeof hb) < 0) { close(fd); return NULL; } + + send_meta(fd, 2); /* 首条注册媒体源(state=0 未播也登记,start 播后自动成源) */ + + uint64_t last_meta = 0; + while (!g_quit && !g_app_quit) { + struct pollfd p; + p.fd = fd; p.events = POLLIN; + int pr = poll(&p, 1, 200); + if (pr > 0 && (p.revents & POLLIN)) { + uint8_t rbuf[MEOW_IPC_MAX]; + uint16_t type; + int n = read_frame(fd, &type, rbuf, sizeof rbuf); + if (n < 0) break; + if (type == MSG_PLAYCTL && n >= (int)sizeof(m_playctl_t)) { + m_playctl_t c; + memcpy(&c, rbuf, sizeof c); + apply_playctl(&c); + } + } else if (pr < 0 && errno != EINTR) { + break; + } + uint64_t now = meow_ui_time_ms(); + if (now - last_meta >= 1000) { + send_meta(fd, (uint32_t)(now & 0x7FFFFFFF)); + last_meta = now; + } + } + close(fd); + return NULL; +} + +int ipc_media_start(void) +{ + if (g_thr_on) return 0; + g_quit = 0; + if (pthread_create(&g_thr, NULL, ipc_thread, NULL) != 0) return -1; + pthread_detach(g_thr); + g_thr_on = 1; + return 0; +} + +void ipc_media_stop(void) +{ + g_quit = 1; + /* 线程在 200ms poll 内退出(detach,不 join)*/ + g_thr_on = 0; +} diff --git a/patch_v2/ncm_app/app/play/minimp3_impl.c b/patch_v2/ncm_app/app/play/minimp3_impl.c new file mode 100644 index 0000000..e524e18 --- /dev/null +++ b/patch_v2/ncm_app/app/play/minimp3_impl.c @@ -0,0 +1,4 @@ +/* play/minimp3_impl.c — minimp3(x) 唯一实现 TU(header-only:define 实现一次)。 + * 编译时 -I/minimp3。宿主与 mipsel 共用。 */ +#define MINIMP3_IMPLEMENTATION +#include "minimp3_ex.h" diff --git a/patch_v2/ncm_app/app/play/player_manager.c b/patch_v2/ncm_app/app/play/player_manager.c new file mode 100644 index 0000000..3a20afc --- /dev/null +++ b/patch_v2/ncm_app/app/play/player_manager.c @@ -0,0 +1,321 @@ +/* play/player_manager.c — 播放线程:URL 获取(Hi-res 逐档降级)→缓存(命中直接播/否则流式落盘)→ + * minimp3 解码→audio_out 直写。UI 不卡(网络+解码全在此线程)。 + * + * 模型:一个持久的播放线程。外层循环取「目标曲目」,run_song() 负责一首歌的 + * 「下载(缓冲) → 解码 → 输出 → 结束/被指令打断」。暂停=冻结(停读停写停推 pos,状态置 PAUSED); + * seek=重开解码器+输出并 seek 到目标 ms(字节 offset 近似);下一/上一首=打断当前 run_song 返回外层。 + * 所有跨线程共享状态在 g_play (mu)。g_st.ctx 的 device_id/cookie/cache 在播放期间稳定(登出会先停播)。 + * 返回码/错误经 g_play.err/errmsg 供 UI 提示。 + */ +#include "../ncm_app_internal.h" +#include +#include +#include +#include +#include +#include "minimp3_ex.h" + +static pthread_t g_thr; +static int g_thr_on; +static int g_want_run = 1; /* 线程运行标志(quit=0) */ +#define PCM_CHUNK 8192 /* 每块交织样本数(双声道 → 4096 帧) */ + +/* ---- 线程内部一次性歌的状态 ---- */ +static char g_path[512]; /* 本地 mp3 路径(缓存) */ +static int64_t g_file_bytes; +static int g_level; /* 音质档(来自设置) */ +static int g_cache_gb; + +int player_manager_state(void) { int s; pthread_mutex_lock(&g_play.mu); s = g_play.state; pthread_mutex_unlock(&g_play.mu); return s; } +int player_manager_playing_song(void) { int s; pthread_mutex_lock(&g_play.mu); s = g_play.cur_song; pthread_mutex_unlock(&g_play.mu); return s; } +int64_t player_manager_pos_ms(void) { int64_t p; pthread_mutex_lock(&g_play.mu); p = g_play.pos_ms; pthread_mutex_unlock(&g_play.mu); return p; } +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; } + +void player_manager_set_quality(int level, int cache_gb) +{ + g_level = level; + g_cache_gb = cache_gb; + pthread_mutex_lock(&g_play.mu); + g_play.err = 0; + ncm_cache_set_limit_gb(&g_st.ctx.cache, cache_gb); + pthread_mutex_unlock(&g_play.mu); +} + +void player_manager_play(int song_idx) +{ + pthread_mutex_lock(&g_play.mu); + g_play.cur_song = song_idx; + g_play.cmd = 0; + g_play.state = NCM_PC_BUFFER; + g_play.pos_ms = 0; + g_play.seq++; + pthread_mutex_unlock(&g_play.mu); +} + +void player_manager_pause(int on) +{ + pthread_mutex_lock(&g_play.mu); + if (on) { if (g_play.state == NCM_PC_PLAYING) g_play.state = NCM_PC_PAUSED; } + else { if (g_play.state == NCM_PC_PAUSED) g_play.state = NCM_PC_PLAYING; } + pthread_mutex_unlock(&g_play.mu); +} + +void player_manager_seek(int64_t ms) +{ + pthread_mutex_lock(&g_play.mu); + g_play.cmd = NCM_CTL_SEEK; + g_play.cmd_seek_ms = ms; + pthread_mutex_unlock(&g_play.mu); +} + +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_play.cur_song) { + g_play.cur_song = c; + g_play.cmd = 0; + g_play.state = NCM_PC_BUFFER; + g_play.pos_ms = 0; + g_play.seq++; + } + pthread_mutex_unlock(&g_play.mu); +} + +void player_manager_stop(void) +{ + pthread_mutex_lock(&g_play.mu); + g_play.cur_song = -1; + g_play.state = NCM_PC_STOPPED; + g_play.pos_ms = 0; + g_play.seq++; + pthread_mutex_unlock(&g_play.mu); +} + +/* ---- 每首歌的播放体(在外层循环内调用)---- */ +static int download_cb(void *ctx, const unsigned char *data, size_t len) +{ + return ncm_cache_write((ncm_cache_writer_t *)ctx, data, len); +} + +/* 得到本地 mp3 路径 + 文件大小。命中缓存直接返回;否则 URL 降级 + 流式落缓存。 + * 返回 NCM_OK 或负错误码。 */ +static int song_local_file(int idx, 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; + if (stat(path, &st) == 0) { *bytes = (int64_t)st.st_size; return NCM_OK; } + } + + /* 播放地址:从 g_level 降级,只收 mp3 */ + ncm_play_url_t u; + int rc = ncm_api_song_url_auto(g_st.ctx.device_id, g_st.ctx.cookie, id, g_level, &u); + if (rc != NCM_OK) return rc; + if (!u.url[0]) return NCM_ERR_NOTFOUND; + + /* 流式下载到缓存 temp → commit(稳妥者:先落盘再播) */ + ncm_cache_writer_t *w = NULL; + rc = ncm_cache_open(&g_st.ctx.cache, id, ".mp3", &w); + if (rc != NCM_OK) return rc; + + ncm_http_req_t req; + memset(&req, 0, sizeof req); + req.url = u.url; + req.method = "GET"; + req.extra_hdrs = "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; + req.verify_ca = NCM_HTTP_VERIFY_DEFAULT; + req.timeout_ms = 30000; + + ncm_http_resp_t resp; + size_t maxb = (u.size > 0 && u.size < (64LL << 20)) ? (size_t)u.size : (64u << 20); + rc = ncm_http_download(&req, download_cb, w, maxb, &resp); + if (rc != NCM_OK || resp.status != 200) { + ncm_cache_abort(w); + return (rc != NCM_OK) ? rc : NCM_ERR_HTTP; + } + rc = ncm_cache_commit(w); + if (rc != 0) return rc; + if (ncm_cache_lookup(&g_st.ctx.cache, id, ".mp3", path, psz) != NCM_OK) + return NCM_ERR_NOENT; + struct stat st; + if (stat(path, &st) == 0) *bytes = (int64_t)st.st_size; + return NCM_OK; +} + +static void set_err(int err, const char *msg) +{ + pthread_mutex_lock(&g_play.mu); + g_play.err = err; + if (msg) { ncm_strlcpy(g_play.errmsg, msg, sizeof g_play.errmsg); } + g_play.state = NCM_PC_STOPPED; + pthread_mutex_unlock(&g_play.mu); +} + +/* 一首歌:下载→解码→输出,直到结束或被打断。返回后由外层处理下一首。 */ +static void run_song(int idx) +{ + int64_t file_bytes = 0; + char path[512]; + int rc = song_local_file(idx, 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"; + set_err(rc, m); + return; + } + 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; + + mp3dec_ex_t dec; + memset(&dec, 0, sizeof dec); + if (mp3dec_ex_open(&dec, path, MP3D_SEEK_TO_BYTE) != 0) { set_err(NCM_ERR_IO, "decode open failed"); return; } + + /* 音频输出:解码器输出 S16_LE(minimp3 默认);用 44100/2ch 直开 */ + if (audio_out_open(44100, 2, 16) != 0) { mp3dec_ex_close(&dec); set_err(NCM_ERR_IO, "audio open failed"); return; } + + /* 状态→播放 */ + pthread_mutex_lock(&g_play.mu); + g_play.state = NCM_PC_PLAYING; + g_play.pos_ms = 0; + g_play.dur_ms = dur; + g_play.err = 0; + pthread_mutex_unlock(&g_play.mu); + + mp3d_sample_t pcm[PCM_CHUNK]; + int64_t frames_done = 0; /* 已输出帧(每通道) */ + int need_seek_ms = -1; + + for (;;) { + if (g_app_quit) break; + + /* 取命令 */ + pthread_mutex_lock(&g_play.mu); + int cmd = g_play.cmd; g_play.cmd = 0; + int64_t seek_ms = g_play.cmd_seek_ms; + int cur = g_play.cur_song; + int st = g_play.state; + pthread_mutex_unlock(&g_play.mu); + + if (cur != idx) break; /* 切歌 → 回外层 */ + if (st == NCM_PC_STOPPED) break; + + if (cmd == NCM_CTL_SEEK && seek_ms >= 0) { need_seek_ms = (int)seek_ms; } + + if (st == NCM_PC_PAUSED) { usleep(40 * 1000); continue; } /* 冻结 */ + + if (need_seek_ms >= 0) { + /* 重开并 seek:关闭后重开(字节 offset 近似) */ + audio_out_close(); + mp3dec_ex_close(&dec); + memset(&dec, 0, sizeof dec); + if (mp3dec_ex_open(&dec, path, MP3D_SEEK_TO_BYTE) != 0) { set_err(NCM_ERR_IO, "seek reopen failed"); break; } + if (audio_out_open(44100, 2, 16) != 0) { set_err(NCM_ERR_IO, "audio reopen failed"); break; } + int64_t target = (g_file_bytes > 0 && dur > 0) + ? (int64_t)g_file_bytes * need_seek_ms / dur : 0; + mp3dec_ex_seek(&dec, (uint64_t)(target > 0 ? target : 0)); + frames_done = (int64_t)need_seek_ms * 44100 / 1000; + need_seek_ms = -1; + continue; + } + + size_t got = mp3dec_ex_read(&dec, pcm, PCM_CHUNK); + if (got == 0) break; /* 播完 */ + int frames = (int)(got / 2); + if (audio_out_write(pcm, frames) != frames) break; + frames_done += frames; + /* 更新进度(~200ms 节流)*/ + int64_t pos = frames_done * 1000 / 44100; + pthread_mutex_lock(&g_play.mu); + g_play.pos_ms = pos; + if (dur > 0 && pos >= dur) g_play.pos_ms = dur; + pthread_mutex_unlock(&g_play.mu); + } + + audio_out_close(); + mp3dec_ex_close(&dec); + /* 若应继续播下一首且非因命令打断,由外层处理(外层见 cur==idx 且需自然结束) */ +} + +/* 外层播放线程循环 */ +static void *player_thread(void *arg) +{ + (void)arg; + while (g_want_run && !g_app_quit) { + pthread_mutex_lock(&g_play.mu); + int cur = g_play.cur_song; + int st = g_play.state; + pthread_mutex_unlock(&g_play.mu); + + if (cur < 0 || cur >= g_st.song_count || st == NCM_PC_STOPPED) { + usleep(60 * 1000); + continue; + } + /* 以本地快照播放(idx 被外层线程独占,无需再锁) */ + run_song(cur); + + pthread_mutex_lock(&g_play.mu); + int cur2 = g_play.cur_song; + 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) { + int nx = cur + 1; + if (nx < g_st.song_count) { player_manager_next(1); } + else { player_manager_stop(); } + } else { + usleep(50 * 1000); + } + } + return NULL; +} + +/* 为当前曲目拉歌词并解析(UI 线程调):buf 有界,解析后存 lyric 模块。 */ +int player_manager_load_lyric(void) +{ + lyric_clear(); + int idx = player_manager_playing_song(); + if (idx < 0 || idx >= g_st.song_count) return NCM_ERR_ARG; + const ncm_song_t *s = &g_st.songs[idx]; + char *lrc = (char *)malloc(64 * 1024); + char *tl = (char *)malloc(32 * 1024); + if (!lrc || !tl) { free(lrc); free(tl); return NCM_ERR_OOM; } + int rc = ncm_api_lyric(g_st.ctx.device_id, g_st.ctx.cookie, s->id, lrc, 64 * 1024, tl, 32 * 1024); + if (rc == NCM_OK) lyric_parse(lrc, tl); + free(lrc); + free(tl); + return rc; +} + +int player_manager_start(void) +{ + if (g_thr_on) return 0; + g_want_run = 1; + g_level = g_st.level; + g_cache_gb = g_st.cache_limit_gb; + pthread_mutex_init(&g_play.mu, NULL); + if (pthread_create(&g_thr, NULL, player_thread, NULL) != 0) return -1; + g_thr_on = 1; + return 0; +} + +void player_manager_stop_thread(void) +{ + g_want_run = 0; + g_app_quit = 1; + audio_out_close(); /* 打断阻塞写 */ + if (g_thr_on) { + pthread_join(g_thr, NULL); + g_thr_on = 0; + } +} diff --git a/patch_v2/ncm_app/app/test/test_decode.c b/patch_v2/ncm_app/app/test/test_decode.c new file mode 100644 index 0000000..26a51f6 --- /dev/null +++ b/patch_v2/ncm_app/app/test/test_decode.c @@ -0,0 +1,67 @@ +/* test/test_decode.c — 播放解码链路验证(宿主):minimp3 解码 mp3 → audio_out(FILE sink) 写 WAV。 + * 不依赖网络/登录(输入本地 mp3)。验证:解码无错、WAV 非空、时长合理、RT 因子。 + * 运行:NCM_AUDIO_SINK=file NCM_AUDIO_OUT=/tmp/ncm_decode.wav ./test_decode + */ +#include +#include +#include +#include +#include "../ncm_app_internal.h" /* 引 audio_out / minimp3 声明 */ +#include "minimp3_ex.h" + +ncm_app_state_t g_st; +ncm_play_run_t g_play = { .mu = PTHREAD_MUTEX_INITIALIZER }; +volatile int g_app_quit = 0; +volatile int g_net_ready = 0; +int g_host_mock = 1; + +static double now_s(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + ts.tv_nsec / 1e9; +} + +int main(int argc, char **argv) +{ + const char *path = (argc > 1) ? argv[1] : "vendor/minimp3/vectors/performance/MIPSTest.mp3"; + mp3dec_ex_t dec; + memset(&dec, 0, sizeof dec); + if (mp3dec_ex_open(&dec, path, MP3D_SEEK_TO_BYTE) != 0) { fprintf(stderr, "open failed\n"); return 1; } + + static mp3d_sample_t pcm[8192 * 2]; + /* 读首帧确定采样率/声道(read 会填 dec.info)*/ + size_t got = mp3dec_ex_read(&dec, pcm, 8192 * 2); + int sr = dec.info.hz; + int ch = dec.info.channels; + if (sr <= 0 || ch <= 0) { fprintf(stderr, "bad stream\n"); return 1; } + printf("mp3: %s %dHz x%d detected_samples=%lld bitrate=%dkbps\n", + path, sr, ch, (long long)dec.samples, dec.info.bitrate_kbps); + if (audio_out_open(sr, ch, 16) != 0) { fprintf(stderr, "audio_out open failed\n"); return 1; } + + long long frames = 0; + double t0 = now_s(); + if (got > 0) { + int fr = (int)(got / (size_t)ch); + if (audio_out_write(pcm, fr) != fr) { fprintf(stderr, "write failed\n"); return 1; } + frames += fr; + } + for (;;) { + if (g_app_quit) break; + got = mp3dec_ex_read(&dec, pcm, 8192 * 2); + if (got == 0) break; + int fr = (int)(got / (size_t)ch); + if (audio_out_write(pcm, fr) != fr) { fprintf(stderr, "write failed\n"); break; } + frames += fr; + } + double dt = now_s() - t0; + audio_out_close(); + mp3dec_ex_close(&dec); + + double secs = (double)frames / (double)sr; + double rt = (dt > 0) ? secs / dt : 0; + printf("decoded %lld frames = %.2f s audio (%d Hz) in %.3f s => %.1fx RT\n", + frames, secs, sr, dt, rt); + printf("OK: %s\n", (frames > 0) ? "decode chain works" : "EMPTY"); + return (frames > 0) ? 0 : 1; +} diff --git a/patch_v2/ncm_app/app/test/test_ncm_app.c b/patch_v2/ncm_app/app/test/test_ncm_app.c new file mode 100644 index 0000000..b13b7b6 --- /dev/null +++ b/patch_v2/ncm_app/app/test/test_ncm_app.c @@ -0,0 +1,163 @@ +/* test/test_ncm_app.c — 宿主验证:真实页面 view + 真实 SDK 绘制,MEOW_UI_DUMP 导 PPM 截图目检。 + * + * 编译见 build_ncm_host.sh。运行:NCM_PAGE=login|home|playlist|player|lands|settings \ + * MEOW_UI_DUMP=/tmp/ncm_.ppm ./ncm_host + * 不依赖 /dev/fb0(SDK 宿主导出 480x720 离屏);meow_font 打桩(ASCII 5x7;中文不渲染,仅验布局)。 + * 数据全 mock(g_host_mock=1,页面跳过网络)。 + */ +#include "../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 = 1; + +static void mock_playlists(void) +{ + static const char *names[] = { "My Favorites", "Focus Mix", "Commute", "Workout", "Sleep" }; + static const int counts[] = { 128, 56, 34, 22, 15 }; + g_st.playlist_count = 5; + g_st.logged_in = 1; + ncm_strlcpy(g_st.nickname, "Tester", sizeof g_st.nickname); + for (int i = 0; i < 5; i++) { + snprintf(g_st.playlists[i].id, sizeof g_st.playlists[i].id, "pl%d", i); + snprintf(g_st.playlists[i].name, sizeof g_st.playlists[i].name, "%s", names[i]); + g_st.playlists[i].track_count = counts[i]; + snprintf(g_st.playlists[i].cover, sizeof g_st.playlists[i].cover, + "http://p1.music.126.net/cover%d.jpg", i); + } +} + +static void mock_songs(void) +{ + static const char *sn[] = { "Song One", "Song Two", "Song Three", + "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.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); + snprintf(g_st.songs[i].name, sizeof g_st.songs[i].name, "%s", sn[i]); + snprintf(g_st.songs[i].art_name, sizeof g_st.songs[i].art_name, "%s", ar[i]); + 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; + } + ncm_strlcpy(g_st.cover_url, "http://p1.music.126.net/cover0.jpg", sizeof g_st.cover_url); + g_st.playing_song = 0; +} + +static void mock_lyric(void) +{ + static const char *lrc = + "[00:00.00]Intro\n[00:05.00]First line of the song\n" + "[00:10.50]Second line here\n[00:15.20]Third line with translation\n" + "[00:20.80]Fourth line\n[00:26.10]Fifth\n[00:31.40]Sixth line\n"; + static const char *tl = + "[00:10.50]Second translation\n[00:15.20]Third translation\n"; + lyric_parse(lrc, tl); +} + +static void mock_qr(void) +{ + g_st.qr_size = 29; + for (int y = 0; y < 29; y++) + for (int x = 0; x < 29; x++) { + int border = (x < 3 || x >= 26 || y < 3 || y >= 26); + int finder = (x < 7 && y < 7) || (x >= 22 && y < 7) || (x < 7 && y >= 22); + g_st.qr_mods[y * 29 + x] = border || finder || ((x ^ y) & 1); + } + g_st.login_state = NCM_LOGIN_PENDING; +} + +static int page_id(const char *name) +{ + if (strcmp(name, "login") == 0) return ACT_LOGIN; + if (strcmp(name, "home") == 0) return ACT_HOME; + if (strcmp(name, "playlist") == 0) return ACT_PLAYLIST; + if (strcmp(name, "player") == 0) return ACT_PLAYER; + if (strcmp(name, "lands") == 0) return ACT_PLAYER; /* 横屏布局 */ + if (strcmp(name, "settings") == 0) return ACT_SETTINGS; + return -1; +} + +int main(int argc, char **argv) +{ + const char *name = getenv("NCM_PAGE"); + if (!name || !name[0]) name = "home"; + char dump[512]; + const char *dp = getenv("MEOW_UI_DUMP"); + if (!dp || !dp[0]) snprintf(dump, sizeof dump, "/tmp/ncm_%s.ppm", name); + else snprintf(dump, sizeof dump, "%s", dp); + setenv("MEOW_UI_DUMP", dump, 1); + (void)argc; (void)argv; + + meow_ui_opts o = { APP_W, APP_H }; + if (meow_ui_init(&o) != 0) { fprintf(stderr, "init failed\n"); return 1; } + meow_theme_add_listener(NULL); + + /* mock 状态 */ + if (g_st.cache_dir[0] == 0) { + snprintf(g_st.cache_dir, sizeof g_st.cache_dir, "/tmp/ncm_host_cache"); + } + if (strcmp(name, "login") == 0) mock_qr(); + else if (strcmp(name, "loginlive") == 0) { /* 真实 unikey → 真实二维码 */ + ncm_ctx_init(&g_st.ctx, "/tmp/ncm_host_login"); + char unikey[64]; + int rc = ncm_login_unikey(g_st.ctx.device_id, unikey, sizeof unikey); + if (rc != NCM_OK) { fprintf(stderr, "unikey fetch failed rc=%d\n", rc); return 1; } + ncm_login_qr_url(unikey, g_st.qr_url, sizeof g_st.qr_url); + int sz = 0; + if (ncm_login_qr_matrix(g_st.qr_url, g_st.qr_mods, 177, &sz) != NCM_OK) { + fprintf(stderr, "qr matrix failed\n"); return 1; + } + g_st.qr_size = sz; + g_st.login_state = NCM_LOGIN_PENDING; + printf("live unikey=%s qr_url=%s size=%d\n", unikey, g_st.qr_url, sz); + } + else if (strcmp(name, "playlist") == 0) { mock_playlists(); mock_songs(); } + else if (strcmp(name, "player") == 0 || strcmp(name, "lands") == 0) { + mock_playlists(); mock_songs(); mock_lyric(); + pthread_mutex_lock(&g_play.mu); + g_play.state = NCM_PC_PLAYING; + g_play.cur_song = 0; + g_play.pos_ms = 94000; + g_play.dur_ms = 180000; + pthread_mutex_unlock(&g_play.mu); + } else if (strcmp(name, "settings") == 0) { + g_st.logged_in = 1; + ncm_strlcpy(g_st.nickname, "Tester", sizeof g_st.nickname); + g_st.level = NCM_LEVEL_HIRES; + g_st.cache_limit_gb = 10; + } else { + mock_playlists(); + } + + meow_view *v = NULL; + if (strcmp(name, "login") == 0 || strcmp(name, "loginlive") == 0) v = login_view_create(); + else if (strcmp(name, "home") == 0) v = home_view_create(); + else if (strcmp(name, "playlist") == 0) v = playlist_view_create(); + else if (strcmp(name, "player") == 0 || strcmp(name, "lands") == 0) v = player_view_create(); + else if (strcmp(name, "settings") == 0) v = settings_view_create(); + if (!v) { fprintf(stderr, "bad page\n"); return 1; } + + if (strcmp(name, "lands") == 0) player_set_screen(1); + + int pid = (strcmp(name, "loginlive") == 0) ? ACT_LOGIN : page_id(name); + meow_view_page_push(pid); + /* player 页:其 on_tick 通常驱动进度;这里直接再画一次确保状态就绪 */ + meow_ui_paint_full(); + if (strcmp(name, "player") == 0 || strcmp(name, "lands") == 0) { + player_tick(meow_ui_time_ms()); + } + + meow_ui_shutdown(); + printf("dumped %s -> %s\n", name, dump); + return 0; +} diff --git a/patch_v2/ncm_app/app/ui/chrome.c b/patch_v2/ncm_app/app/ui/chrome.c new file mode 100644 index 0000000..9cc9cf1 --- /dev/null +++ b/patch_v2/ncm_app/app/ui/chrome.c @@ -0,0 +1,104 @@ +/* ui/common/chrome.c — os.meow.ncm 共享 chrome(TopAppBar 建置 / 几何 / 空态&页脚 / 底部提示条)。 + * + * 与 files_app/ui/common/chrome.c 同理:把「页面 chrome 固定不随内容重建」分出去。绘制全部读 + * meow_theme_get() 语义色(§8.1),不硬编码配色。topbar 由 meow_topappbar 建,自动注册进绘制物 + * 注册表(§6.2),owner=创建时 active activity。 + */ +#include "../ncm_app_internal.h" +#include + +int ncm_statusbar(void) +{ + int sb = meow_ui_statusbar_height(); + return sb < 0 ? 0 : sb; +} + +ncm_rect_t ncm_topbar_rect(void) +{ + int sb = ncm_statusbar(); + return NR(0, sb, meow_ui_width(), APP_TOPBAR_H); +} + +ncm_rect_t ncm_content_rect(void) +{ + int sb = ncm_statusbar(); + int y = sb + APP_TOPBAR_H; + int h = meow_ui_height() - y; + if (h < 0) h = 0; + return NR(0, y, meow_ui_width(), h); +} + +meow_widget *ncm_topbar_create(const char *title, int back, const char *action_label) +{ + ncm_rect_t r = ncm_topbar_rect(); + meow_widget *tb = meow_topappbar_create(r.x, r.y, r.w, r.h, title); + meow_topappbar_set_back(tb, back); + if (action_label) meow_topappbar_add_action(tb, action_label); + meow_widget_set_z(tb, 999); /* chrome 置顶(§6.2:> 一切自动 z) */ + return tb; +} + +/* 空态/错误:居中两行(kind 大字 + what 小字)。 */ +void ncm_draw_empty(ncm_rect_t r, const char *kind, const char *what) +{ + const meow_theme *th = meow_theme_get(); + int cy = r.y + r.h / 2; + meow_ui_text_center(r.x + r.w / 2, cy - 34, kind ? kind : "", 28, th->primary); + if (what && what[0]) + meow_ui_text_center(r.x + r.w / 2, cy + 4, what, 20, th->dim); +} + +/* 底部信息(右对齐 dim)。 */ +void ncm_draw_footer(ncm_rect_t r, const char *text) +{ + const meow_theme *th = meow_theme_get(); + if (!text || !text[0]) return; + meow_ui_text_right(r.x + r.w - APP_PAD, r.y + r.h - 26, text, 20, th->dim); +} + +/* 底部提示条(非阻塞):画在内容区底部之上,2s 后由 tick 清除。 + * 只画一次并标脏;g_snack_until 为过期时间(0=无)。 */ +static char s_snack[192]; +static uint64_t s_snack_until; /* monotonic ms 过期时刻 */ +static ncm_rect_t s_snack_r; +static int s_snack_id; /* <0=未注册 */ + +static void snack_draw(void *ctx) +{ + (void)ctx; + if (meow_ui_time_ms() >= s_snack_until && s_snack_until != 0) { + meow_ui_invalidate(s_snack_r.x, s_snack_r.y, s_snack_r.w, s_snack_r.h); + s_snack_until = 0; + return; + } + const meow_theme *th = meow_theme_get(); + /* 深色底 + 白字 */ + meow_ui_fill_rect(s_snack_r.x, s_snack_r.y, s_snack_r.w, s_snack_r.h, th->inverseSurface); + meow_ui_text_center(s_snack_r.x + s_snack_r.w / 2, s_snack_r.y + (s_snack_r.h - 26) / 2 + 4, + s_snack, 20, th->inverseOnSurface); +} + +void ncm_snack(const char *text) +{ + if (!text) return; + strncpy(s_snack, text, sizeof s_snack - 1); + s_snack[sizeof s_snack - 1] = 0; + s_snack_until = meow_ui_time_ms() + 2000; + int w = meow_ui_width() - 2 * APP_PAD; + s_snack_r = NR(APP_PAD, meow_ui_height() - 56, w, 40); + if (s_snack_id < 0) + s_snack_id = meow_draw_custom_register(999, s_snack_r.x, s_snack_r.y, s_snack_r.w, + s_snack_r.h, snack_draw, NULL); + else + meow_draw_set_z(s_snack_id, 999); + meow_ui_invalidate(s_snack_r.x, s_snack_r.y, s_snack_r.w, s_snack_r.h); +} + +/* 周期检查:snack 到期清除。可由各页 tick 调。 */ +void chrome_tick(void) +{ + if (s_snack_until && meow_ui_time_ms() >= s_snack_until) { + meow_ui_invalidate(s_snack_r.x, s_snack_r.y, s_snack_r.w, s_snack_r.h); + s_snack_until = 0; + } +} diff --git a/patch_v2/ncm_app/app/ui/home.c b/patch_v2/ncm_app/app/ui/home.c new file mode 100644 index 0000000..bf5cb86 --- /dev/null +++ b/patch_v2/ncm_app/app/ui/home.c @@ -0,0 +1,220 @@ +/* ui/home.c — 我的歌单页(ACT_HOME,根页)。 + * + * 内容区用 SDK 结构化列表(meow_listview,§5.5 W_LISTVIEW)虚拟化渲染 + * 「图标(folder 占位) + 歌单名 + 曲目数副标题 + 右侧箭头」。行数据源逐行填 meow_list_item; + * 滚动/选中/命中由 SDK 收口。空态/错误/底部信息行用 meow_draw_custom_register 注册非控件绘制物。 + * 左缘返回 = 退出 App(app.c 处理)。下拉刷新可选:用 topbar 「↻」 动作项。 + */ +#include "../ncm_app_internal.h" +#include +#include +#include + +typedef struct { meow_view *view; int active; } home_ctrl; +static home_ctrl g_hc; + +static meow_widget *g_tb; +static meow_widget *g_list; +static ncm_rect_t r_cont, r_list; +static int g_empty_id, g_footer_id; +static char g_title[128]; +static int g_load_err; /* 0=ok;负=错误码 */ +static int g_press_row, g_press_delta, g_dragging, g_drag_last_y; + +static void home_drag_reset(void) +{ + g_press_row = -1; + g_press_delta = 0; + g_dragging = 0; + g_drag_last_y = 0; +} + +static int home_src2(int idx, meow_list_item *out, void *ctx) +{ + (void)ctx; + if (idx < 0 || idx >= g_st.playlist_count) return 0; + ncm_playlist_t *pl = &g_st.playlists[idx]; + static char title[128]; /* 歌单名截断 */ + static char sub[64]; + snprintf(title, sizeof title, "%s", pl->name); + snprintf(sub, sizeof sub, "%d tracks", pl->track_count); + out->icon_kind = MEOW_LIST_ICON_FOLDER; + out->title = title; + out->subtitle = sub; + out->right = MEOW_LIST_RIGHT_ARROW; + out->on = 0; + out->enabled = 1; + return 1; +} + +static void home_draw_empty(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_HOME) return; + if (g_load_err == 0 && g_st.playlist_count != 0) return; + if (g_st.logged_in == 0) + ncm_draw_empty(r_list, "NOT SIGNED IN", "sign in first"); + else if (g_load_err == NCM_ERR_AUTH) + ncm_draw_empty(r_list, "SESSION EXPIRED", "please sign in again"); + else if (g_load_err) + ncm_draw_empty(r_list, "NETWORK ERROR", "pull to retry"); + else + ncm_draw_empty(r_list, "NO PLAYLISTS", "nothing here yet"); +} + +static void home_draw_footer(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_HOME) return; + if (g_load_err != 0) return; + char foot[96]; + if (g_st.playlist_count == 0) + snprintf(foot, sizeof foot, "0 playlists"); + else + snprintf(foot, sizeof foot, "%d playlists %s", g_st.playlist_count, + g_st.nickname[0] ? g_st.nickname : ""); + ncm_draw_footer(r_cont, foot); +} + +void home_reload(void) +{ + g_load_err = 0; + int rc = 0; + if (g_host_mock) { /* 宿主/测试:跳过网络,用预置数据 */ + rc = 0; + } else if (!g_st.logged_in) rc = NCM_ERR_AUTH; + else rc = appstate_load_playlists(); + g_load_err = rc; + if (g_load_err != 0) g_st.playlist_count = 0; + if (g_list) { + meow_listview_set_count(g_list, g_st.playlist_count); + meow_listview_set_scroll(g_list, 0); + meow_listview_set_sel(g_list, -1); + } + const char *name = g_st.nickname[0] ? g_st.nickname : "My Playlists"; + snprintf(g_title, sizeof g_title, "%s", name); + if (g_tb) meow_topappbar_set_title(g_tb, g_title); + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->bg); + meow_ui_paint_full(); +} + +int home_is_active(void) { return g_hc.active; } + +int home_on_tap(int x, int y) +{ + if (g_tb) { + int ah = meow_topappbar_hit(g_tb, x, y); + if (ah == MEOW_TOPBAR_BACK) { home_drag_reset(); return 1; } + if (ah >= 0) { home_drag_reset(); home_reload(); return 1; } /* ↻ 刷新 */ + } + if (g_load_err != 0 || g_st.playlist_count == 0) return 0; + if (meow_widget_hit(g_list, x, y)) { + int row = meow_listview_hit(g_list, x, y); + if (row < 0) { home_drag_reset(); return 1; } + home_drag_reset(); + playlist_open(row); + return 1; + } + return 0; +} + +void home_touch_down(int x, int y) +{ + home_drag_reset(); + if (g_load_err != 0 || g_st.playlist_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; + meow_listview_set_sel(g_list, 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 home_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; meow_listview_set_sel(g_list, -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 home_swipe(int dir, int dist) +{ + if (dir != MEOW_SWIPE_UP && dir != MEOW_SWIPE_DOWN) return; + if (g_load_err != 0 || g_st.playlist_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); +} + +/* 生命周期 */ +static void home_page_create(meow_view *v, void *ctx) +{ + (void)v; + ((home_ctrl *)ctx)->view = v; + r_cont = ncm_content_rect(); + r_list = NR(r_cont.x, r_cont.y, r_cont.w, r_cont.h - APP_FOOTER_H); + if (r_list.h < 0) r_list.h = 0; + meow_ui_activity_push(ACT_HOME); /* 让下面控件 owner=ACT_HOME(§6.1 ①) */ + g_tb = ncm_topbar_create("My Playlists", 0, "\xe2\x86\xbb"); /* 无返回;右侧 ↻ */ + g_list = meow_listview_create(r_list.x, r_list.y, r_list.w, r_list.h, 0, + home_src2, NULL, MEOW_LIST_SEL_SINGLE); + meow_listview_set_item_h(g_list, APP_ROW_H); + meow_ui_activity_pop(); + g_empty_id = meow_draw_custom_register(1, r_list.x, r_list.y, r_list.w, r_list.h, + home_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, home_draw_footer, NULL); + home_drag_reset(); +} + +static void home_page_enter(meow_view *v, void *ctx) +{ + (void)v; + ((home_ctrl *)ctx)->active = 1; + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->bg); + home_reload(); +} + +static void home_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((home_ctrl *)ctx)->active = 0; +} + +static void home_page_destroy(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + 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 *home_view_create(void) +{ + static const meow_view_hooks h = { + .on_create = home_page_create, + .on_enter = home_page_enter, + .on_leave = home_page_leave, + .on_destroy = home_page_destroy, + }; + return meow_view_create(ACT_HOME, MEOW_VIEW_ACTIVITY, &h, &g_hc); +} diff --git a/patch_v2/ncm_app/app/ui/login.c b/patch_v2/ncm_app/app/ui/login.c new file mode 100644 index 0000000..213c297 --- /dev/null +++ b/patch_v2/ncm_app/app/ui/login.c @@ -0,0 +1,215 @@ +/* ui/login.c — 扫码登录页(ACT_LOGIN,根页)。 + * + * 流程:进入即起后台 worker(login_worker): + * unikey(eapi) → 本地二维码矩阵(qrcodegen) → 轮询(801/802/803) → 803 捕获 set-cookie 加密落盘 + * → login_status 拿 uid/nickname → 置 g_login_done。 + * UI 线程只在 tick 读 g_st.qr 状态与 login_state 重绘并检查 g_login_done → 切入 home。 + * 800(过期)自动刷新二维码。UI 不卡:网络全在 worker 线程。 + */ +#include "../ncm_app_internal.h" +#include +#include +#include + +static pthread_t g_thread; +static volatile int g_polling; /* worker 运行中 */ +static volatile int g_done; /* 登录成功(UI tick 切入 home) */ +static volatile int g_err; /* 最后一次错误(网络不可达等) */ +static int g_active; + +static int g_custom_id; /* 绘制物 */ +static ncm_rect_t r_cont; + +/* ---- 绘制 ---- */ +static void login_draw(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_LOGIN) return; + const meow_theme *th = meow_theme_get(); + + /* 标题 */ + meow_ui_text_center(r_cont.x + r_cont.w/2, r_cont.y + 24, "NetEase Cloud Music", 28, th->fg); + meow_ui_text_center(r_cont.x + r_cont.w/2, r_cont.y + 64, "scan to sign in", 20, th->dim); + + /* QR 位图 */ + int sz = g_st.qr_size; + if (sz > 0) { + int qs = sz * 6; /* 每模块 6px → 41x41=246,33x33=198 */ + if (qs > 320) qs = sz * 4; + int ox = r_cont.x + (r_cont.w - qs) / 2; + int oy = r_cont.y + 108; + /* 白色底 + 边框 */ + meow_ui_fill_rect(ox - 8, oy - 8, qs + 16, qs + 16, 0xFFFFFF); + meow_ui_draw_rect(ox - 8, oy - 8, qs + 16, qs + 16, th->outline); + for (int y = 0; y < sz; y++) + for (int x = 0; x < sz; x++) + if (g_st.qr_mods[y*sz + x]) + meow_ui_fill_rect(ox + x*6, oy + y*6, 6, 6, 0x000000); + } else { + meow_ui_text_center(r_cont.x + r_cont.w/2, r_cont.y + 180, + g_err ? "no network - tap to retry" : "fetching QR key...", 20, th->dim); + } + + /* 状态文字 */ + const char *st = login_status_str(g_st.login_state); + if (g_st.login_state == NCM_LOGIN_OK && g_done) st = "success"; + meow_ui_text_center(r_cont.x + r_cont.w/2, r_cont.y + 108 + 320 + 44, + st, 20, (g_st.login_state == NCM_LOGIN_OK) ? th->primary : th->dim); +} + +/* 重绘 login 内容(QR+状态变化时)。 */ +static void login_repaint(void) +{ + meow_ui_invalidate(r_cont.x, r_cont.y, r_cont.w, r_cont.h); +} + +const char *login_status_str(int state) +{ + switch (state) { + case NCM_LOGIN_PENDING: return "waiting for scan..."; + case NCM_LOGIN_SCANNED: return "scanned - confirm on phone"; + case NCM_LOGIN_OK: return "signed in!"; + case NCM_LOGIN_EXPIRED: return "QR expired - refreshing"; + default: return "error, tap to retry"; + } +} + +static int get_unikey_and_qr(char *unikey, size_t usz) + +{ + int rc = ncm_login_unikey(g_st.ctx.device_id, unikey, usz); + if (rc != NCM_OK) return rc; + ncm_strlcpy(g_st.unikey, unikey, sizeof g_st.unikey); + ncm_login_qr_url(unikey, g_st.qr_url, sizeof g_st.qr_url); + int size = 0; + if (ncm_login_qr_matrix(g_st.qr_url, g_st.qr_mods, sizeof g_st.qr_mods / 177, &size) != NCM_OK) + g_st.qr_size = 0; + else + g_st.qr_size = size; + g_st.login_state = NCM_LOGIN_PENDING; + g_err = 0; + return NCM_OK; +} + +static void *login_worker(void *arg) +{ + (void)arg; + char unikey[64]; + if (get_unikey_and_qr(unikey, sizeof unikey) != NCM_OK) { g_err = 1; return NULL; } + + char cookie[NCM_COOKIE_MAX]; + ncm_strlcpy(cookie, g_st.ctx.cookie, sizeof cookie); + + while (!g_app_quit) { + ncm_login_poll_t r; + char out_cookie[NCM_COOKIE_MAX]; + int rc = ncm_login_poll(g_st.ctx.device_id, unikey, cookie, &r, + out_cookie, sizeof out_cookie); + if (rc != NCM_OK) { g_err = 1; usleep(2 * 1000 * 1000); continue; } + g_st.login_state = r.state; + + if (r.state == NCM_LOGIN_OK) { + ncm_strlcpy(cookie, out_cookie, sizeof cookie); + ncm_ctx_set_cookie(&g_st.ctx, cookie); + /* 拿 uid/nickname */ + char uid[32], nick[128]; + if (ncm_login_status_check(g_st.ctx.device_id, cookie, uid, sizeof uid, + nick, sizeof nick) == NCM_OK) + appstate_set_logged_in(uid, nick); + else + appstate_set_logged_in("", ""); + g_st.logged_in = 1; + g_done = 1; + break; + } + if (r.state == NCM_LOGIN_EXPIRED) { + /* 刷新二维码 */ + usleep(500 * 1000); + get_unikey_and_qr(unikey, sizeof unikey); + continue; + } + usleep(1500 * 1000); + } + return NULL; +} + +int login_start(void) +{ + if (g_polling) return 0; + g_polling = 1; + g_done = 0; + g_err = 0; + if (pthread_create(&g_thread, NULL, login_worker, NULL) != 0) { + g_polling = 0; + return -1; + } + pthread_detach(g_thread); + return 0; +} + +int login_is_active(void) { return g_active; } + +void login_on_tap(int x, int y) +{ + (void)x; (void)y; + if (g_st.login_state == NCM_LOGIN_OK && g_done) return; + if (g_err) { login_start(); login_repaint(); } /* 网络失败重试 */ +} + +/* UI tick:重绘 QR/状态变化;登录成功 → 切 home。 */ +void login_tick(uint64_t now) +{ + (void)now; + chrome_tick(); + static int last_state = -1, last_qr = 0; + if (g_st.login_state != last_state || g_st.qr_size != last_qr) { + last_state = g_st.login_state; + last_qr = g_st.qr_size; + login_repaint(); + } + if (g_done) + meow_view_page_replace(ACT_HOME); +} + +/* ---- 生命周期 ---- */ +static void login_page_create(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + r_cont = ncm_content_rect(); + g_custom_id = meow_draw_custom_register(1, r_cont.x, r_cont.y, r_cont.w, r_cont.h, + login_draw, NULL); + g_active = 0; +} + +static void login_page_enter(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + g_active = 1; + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->bg); + meow_ui_paint_full(); + if (!g_host_mock) login_start(); /* 进入即获取二维码 + 轮询 */ +} + +static void login_page_leave(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + g_active = 0; +} + +static void login_page_destroy(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + if (g_custom_id) { meow_draw_unregister(g_custom_id); g_custom_id = 0; } +} + +meow_view *login_view_create(void) +{ + static const meow_view_hooks h = { + .on_create = login_page_create, + .on_enter = login_page_enter, + .on_leave = login_page_leave, + .on_destroy = login_page_destroy, + }; + return meow_view_create(ACT_LOGIN, MEOW_VIEW_ACTIVITY, &h, NULL); +} diff --git a/patch_v2/ncm_app/app/ui/player.c b/patch_v2/ncm_app/app/ui/player.c new file mode 100644 index 0000000..7122c90 --- /dev/null +++ b/patch_v2/ncm_app/app/ui/player.c @@ -0,0 +1,300 @@ +/* ui/player.c — 播放页(ACT_PLAYER)。 + * + * 布局:封面(后台 cover 线程解码) + 标题/歌手 + 进度条(可拖 seek) + 播放/暂停/上下曲 + 滚动歌词 + * (时间轴解析,当前行高亮居中)。播放页横竖屏可切(device LCD 为竖屏 480x720,故为**布局**切换: + * 竖屏=封面在上、歌词在下;横屏=封面在左、信息+歌词在右,仍在 480x720 画布内)。 + * 进度/状态每 tick 读 play_manager(跨线程 mu 保护),歌词行由 pos_ms 推出。 + * 全部内容画进一个 custom 绘制物;控件钮/进度条/歌词命中用 player_draw 计算的共享几何。 + */ +#include "../ncm_app_internal.h" +#include +#include +#include +#include + +typedef struct { meow_view *view; int active; } player_ctrl; +static player_ctrl g_plc; + +static meow_widget *g_tb; +static ncm_rect_t r_cont; +static int g_custom_id; +static int g_landscape; /* 0=竖屏 1=横屏(布局) */ +static int g_last_pos_ms, g_last_state, g_last_line; + +/* 共享几何(player_draw 计算 → player_on_tap 命中用) */ +static ncm_rect_t g_cov; /* 封面 */ +static int g_bar_x, g_bar_y, g_bar_w; +static int g_cprev_x, g_cplay_x, g_cnext_x, g_cby, g_cr; +static int g_lyr_x, g_lyr_y, g_lyr_w, g_lyr_h; /* 歌词区 */ +#define CTL_RR 28 /* 侧钮命中半径 */ + +static int cur_song(void) +{ + int c = player_manager_playing_song(); + return (c >= 0 && c < g_st.song_count) ? c : -1; +} + +static void fmt_ms(int64_t ms, char *out, size_t sz) +{ + if (ms < 0) ms = 0; + int sec = (int)(ms / 1000); + snprintf(out, sz, "%d:%02d", sec / 60, sec % 60); +} + +/* 实心三角近似:right=向右指(顶点在右),left=向左指(顶点在左)。 */ +static void tri(int x0, int y0, int h, int w, int dir, uint32_t color) +{ + for (int i = 0; i < h; i++) { + int wi = 1 + i * w / h; + if (dir == 1) meow_ui_fill_rect(x0, y0 + i, wi, 1, color); + else meow_ui_fill_rect(x0 - wi + 1, y0 + i, wi, 1, color); + } +} + +/* 圆形按钮 + 图标。kind: 0=|<<(prev) 1=>play 2=||pause 3=>>|(next)。 */ +static void draw_ctl(int cx, int cy, int r, int kind, uint32_t base, uint32_t fg) +{ + for (int i = 0; i < 2 * r; i++) { + int dy = i - r; + int half = (int)sqrt((double)(r * r - dy * dy)); + if (half <= 0) continue; + meow_ui_fill_rect(cx - half, cy - r + i, 2 * half, 1, base); + } + int t = r / 2; + if (kind == 0) { /* |<< */ + meow_ui_fill_rect(cx - r - 2, cy - t, 4, 2*t, fg); + tri(cx + r - 4, cy - t, 2*t, 2*t, -1, fg); + } else if (kind == 1) { /* > */ + tri(cx - t, cy - t, 2*t, 2*t, 1, fg); + } else if (kind == 2) { /* || */ + meow_ui_fill_rect(cx - t - 2, cy - t, 4, 2*t, fg); + meow_ui_fill_rect(cx + t - 2, cy - t, 4, 2*t, fg); + } else { /* >>| */ + tri(cx - r + 4, cy - t, 2*t, 2*t, 1, fg); + meow_ui_fill_rect(cx + r - 2, cy - t, 4, 2*t, fg); + } +} + +static void player_draw(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_PLAYER) return; + const meow_theme *th = meow_theme_get(); + int cx = r_cont.x + r_cont.w / 2; + int sc = cur_song(); + const ncm_song_t *s = (sc >= 0) ? &g_st.songs[sc] : NULL; + + /* 封面区域 */ + int covsz = 220; + if (g_landscape) g_cov = NR(r_cont.x + 24, r_cont.y + 60, covsz, covsz); + else g_cov = NR(cx - covsz/2, r_cont.y + 10, covsz, covsz); + + meow_ui_fill_rect(g_cov.x, g_cov.y, g_cov.w, g_cov.h, th->surfaceContainerHigh); + meow_ui_draw_rect(g_cov.x, g_cov.y, g_cov.w, g_cov.h, th->outlineVariant); + meow_img *cov = cover_get(); + if (cov) meow_ui_img_cover(g_cov.x, g_cov.y, covsz, cov); + else meow_ui_text_center(g_cov.x + g_cov.w/2, g_cov.y + g_cov.h/2 - 10, "no artwork", 20, th->dim); + + int info_x = (g_landscape) ? g_cov.x + g_cov.w + 28 : cx; + int info_w = (g_landscape) ? (r_cont.x + r_cont.w - 16 - info_x) : r_cont.w; + + int ty = (g_landscape) ? g_cov.y + 8 : g_cov.y + g_cov.h + 8; + meow_ui_text_center(info_x, ty, s ? s->name : "nothing playing", 28, th->fg); + meow_ui_text_center(info_x, ty + 34, s ? s->art_name : "", 20, th->dim); + + /* 进度条 + 时间 */ + g_bar_x = (g_landscape) ? info_x - info_w/2 : 32; + g_bar_w = (g_landscape) ? info_w - 24 : r_cont.w - 64; + g_bar_y = ty + 78; + int pos = (int)player_manager_pos_ms(); + int dur = (int)player_manager_dur_ms(); + if (dur <= 0 && s) dur = (int)(s->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]; + fmt_ms(pos, lp, sizeof lp); + fmt_ms(dur, rp, sizeof rp); + meow_ui_text(g_bar_x, g_bar_y - 20, lp, 20, th->dim); + meow_ui_text_right(g_bar_x + g_bar_w, g_bar_y - 20, rp, 20, th->dim); + meow_ui_fill_rect(g_bar_x, g_bar_y, g_bar_w, 4, th->surfaceVariant); + meow_ui_fill_rect(g_bar_x, g_bar_y, (int)((int64_t)g_bar_w * frac / 1000), 4, th->primary); + + /* 控制钮 */ + g_cby = g_bar_y + 40; + int cw = 56, gap = (g_landscape) ? 34 : 26; + g_cr = cw/2 + 4; + g_cprev_x = info_x - cw - gap/2 - 4; + g_cplay_x = info_x; + g_cnext_x = info_x + cw + gap/2 + 4; + draw_ctl(g_cprev_x, g_cby, cw/2, 0, th->secondaryContainer, th->onSecondaryContainer); + int st = player_manager_state(); + draw_ctl(g_cplay_x, g_cby, g_cr, (st == NCM_PC_PAUSED) ? 1 : 2, th->primary, th->onPrimary); + draw_ctl(g_cnext_x, g_cby, cw/2, 3, th->secondaryContainer, th->onSecondaryContainer); + + /* 歌词区 */ + if (g_landscape) { + g_lyr_x = r_cont.x + 24; + g_lyr_y = g_cov.y + g_cov.h + 12; + g_lyr_w = r_cont.w - 48; + g_lyr_h = r_cont.y + r_cont.h - g_lyr_y; + } else { + g_lyr_x = r_cont.x; + g_lyr_y = g_cby + g_cr + 10; + g_lyr_w = r_cont.w; + g_lyr_h = r_cont.y + r_cont.h - g_lyr_y; + } + if (g_lyr_h < 0) g_lyr_h = 0; + /* 背景(歌词区隔断) */ + meow_ui_fill_rect(g_lyr_x, g_lyr_y, g_lyr_w, 1, th->surfaceVariant); + + int n = lyric_count(); + int line_h = 28; + int center_y = g_lyr_y + (g_lyr_h > line_h * 11 ? g_lyr_h / 2 : 140); + int lcx = g_lyr_x + g_lyr_w / 2; + if (n == 0) { + meow_ui_text_center(lcx, center_y - 10, "no lyric", 20, th->dim); + return; + } + int64_t ps = player_manager_pos_ms(); + int cur2 = lyric_line_at(ps); + for (int i = cur2 - 6; i <= cur2 + 5; i++) { + if (i < 0 || i >= n) continue; + int yy = center_y + (i - cur2) * line_h; + int is_cur = (i == cur2); + meow_ui_text_center(lcx, yy, lyric_line_text(i), is_cur ? 24 : 20, + is_cur ? th->primary : th->dim); + const char *tl = lyric_line_tl(i); + if (is_cur && tl && tl[0]) + meow_ui_text_center(lcx, yy + 24, tl, 20, th->secondary); + } +} + +int player_is_active(void) { return g_plc.active; } +int player_screen(void) { return g_landscape; } + +int player_set_screen(int landscape) +{ + int old = g_landscape; + g_landscape = landscape ? 1 : 0; + meow_ui_invalidate(r_cont.x, r_cont.y, r_cont.w, r_cont.h); + return old; +} + +void player_on_tap(int x, int y) +{ + if (g_tb) { + int ah = meow_topappbar_hit(g_tb, x, y); + if (ah == MEOW_TOPBAR_BACK) { meow_view_page_pop(); return; } + if (ah >= 0) { player_set_screen(!g_landscape); return; } /* ⟳ 布局切换 */ + } + if (g_bar_w <= 0) return; + /* 控制钮 */ + int r = g_cr; + if (x >= g_cplay_x - r && x <= g_cplay_x + r && y >= g_cby - r && y <= g_cby + r) { + if (player_manager_state() == NCM_PC_PAUSED) player_manager_pause(0); + else player_manager_pause(1); + return; + } + if (x >= g_cprev_x - CTL_RR && x <= g_cprev_x + CTL_RR && + y >= g_cby - CTL_RR && y <= g_cby + CTL_RR) { player_manager_next(-1); return; } + if (x >= g_cnext_x - CTL_RR && x <= g_cnext_x + CTL_RR && + y >= g_cby - CTL_RR && y <= g_cby + CTL_RR) { player_manager_next(1); return; } + /* 进度条 seek */ + if (y >= g_bar_y - 8 && y <= g_bar_y + 12 && x >= g_bar_x && x <= g_bar_x + g_bar_w) { + int64_t dur = player_manager_dur_ms(); + if (dur > 0) player_seek((int64_t)(x - g_bar_x) * dur / g_bar_w); + return; + } +} + +void player_play_song(int idx) +{ + if (idx < 0 || idx >= g_st.song_count) return; + player_manager_play(idx); + appstate_set_playing_song(idx, g_st.cover_url); + cover_request(g_st.cover_url); + meow_view_page_push(ACT_PLAYER); +} + +void player_pause_toggle(int on) +{ + if (player_manager_state() == NCM_PC_PAUSED) player_manager_pause(0); + else player_manager_pause(1); + (void)on; +} + +void player_next(int dir) { player_manager_next(dir); } +void player_seek(int64_t ms) { player_manager_seek(ms); } +void player_on_swipe(int dir, int dist) { (void)dir; (void)dist; } + +void player_tick(uint64_t now) +{ + (void)now; + chrome_tick(); + if (!g_plc.active) return; + + int pos = (int)player_manager_pos_ms(); + int st = player_manager_state(); + int line = (lyric_count() > 0) ? lyric_line_at(pos) : -1; + + if (pos != g_last_pos_ms || st != g_last_state) { + g_last_pos_ms = pos; + g_last_state = st; + meow_ui_invalidate(r_cont.x, r_cont.y, r_cont.w, r_cont.h); + } else if (line != g_last_line) { + g_last_line = line; + meow_ui_invalidate(g_lyr_x, g_lyr_y, g_lyr_w, g_lyr_h); + } + if (cover_changed()) { + cover_ack(); + meow_ui_invalidate(g_cov.x, g_cov.y, g_cov.w, g_cov.h); + } +} + +static void plr_page_create(meow_view *v, void *ctx) +{ + (void)v; + ((player_ctrl *)ctx)->view = v; + r_cont = ncm_content_rect(); + meow_ui_activity_push(ACT_PLAYER); /* owner=ACT_PLAYER */ + g_tb = ncm_topbar_create("Now Playing", 1, "\xe2\x86\xbb"); /* 返回 + ⟳ 布局 */ + meow_ui_activity_pop(); + g_custom_id = meow_draw_custom_register(1, r_cont.x, r_cont.y, r_cont.w, r_cont.h, + player_draw, NULL); + g_last_pos_ms = -1; g_last_state = -1; g_last_line = -1; +} + +static void plr_page_enter(meow_view *v, void *ctx) +{ + (void)v; + ((player_ctrl *)ctx)->active = 1; + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->bg); + meow_ui_paint_full(); + if (!g_host_mock) player_manager_load_lyric(); +} + +static void plr_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((player_ctrl *)ctx)->active = 0; +} + +static void plr_page_destroy(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + if (g_custom_id) { meow_draw_unregister(g_custom_id); g_custom_id = 0; } + if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } +} + +meow_view *player_view_create(void) +{ + static const meow_view_hooks h = { + .on_create = plr_page_create, + .on_enter = plr_page_enter, + .on_leave = plr_page_leave, + .on_destroy = plr_page_destroy, + }; + return meow_view_create(ACT_PLAYER, MEOW_VIEW_ACTIVITY, &h, &g_plc); +} diff --git a/patch_v2/ncm_app/app/ui/playlist.c b/patch_v2/ncm_app/app/ui/playlist.c new file mode 100644 index 0000000..a59d711 --- /dev/null +++ b/patch_v2/ncm_app/app/ui/playlist.c @@ -0,0 +1,216 @@ +/* ui/playlist.c — 歌单详情页(ACT_PLAYLIST,曲目列表)。 + * + * 曲目用 SDK 结构化列表(meow_listview)虚拟化渲染「歌名 + 歌手 副标题」。点击行 → 起播并推播放页。 + * 进入时拉歌单详情(trackIds)+ 分批 song_detail(有界 NCM_SONG_MAX)。空态/错误用绘制物。 + * 左缘返回 = 弹回主页(app.c)。 + */ +#include "../ncm_app_internal.h" +#include +#include +#include + +typedef struct { meow_view *view; int active; } playlist_ctrl; +static playlist_ctrl g_pc; + +static meow_widget *g_tb; +static meow_widget *g_list; +static ncm_rect_t r_cont, r_list; +static int g_empty_id, g_footer_id; +static char g_title[256]; +static int g_load_err; +static int g_press_row, g_press_delta, g_dragging, g_drag_last_y; + +static void pl_drag_reset(void) +{ + g_press_row = -1; g_press_delta = 0; g_dragging = 0; g_drag_last_y = 0; +} + +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]; + out->icon_kind = MEOW_LIST_ICON_NONE; + out->title = s->name; + out->subtitle = (s->art_name[0] || s->album_name[0]) + ? (s->art_name[0] ? s->art_name : s->album_name) : "-"; + out->right = MEOW_LIST_RIGHT_ARROW; + out->on = 0; + out->enabled = 1; + return 1; +} + +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 == NCM_ERR_AUTH) + ncm_draw_empty(r_list, "SESSION EXPIRED", "please sign in again"); + else if (g_load_err) + ncm_draw_empty(r_list, "LOAD FAILED", "tap back and retry"); + else + ncm_draw_empty(r_list, "NO TRACKS", "this playlist is empty"); +} + +static void pl_draw_footer(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_PLAYLIST) return; + if (g_load_err != 0) return; + char foot[96]; + snprintf(foot, sizeof foot, "%d/%d tracks", + g_st.song_count, + (g_st.playlist_count > 0 && g_st.active_playlist >= 0) + ? g_st.playlists[g_st.active_playlist].track_count : g_st.song_count); + ncm_draw_footer(r_cont, foot); +} + +void playlist_refresh(void) +{ + g_load_err = 0; + int rc = NCM_ERR_ARG; + if (!g_host_mock && g_st.active_playlist >= 0) + rc = appstate_load_playlist_songs(g_st.active_playlist); + else if (g_host_mock) + rc = 0; + g_load_err = rc; + if (g_load_err != 0) g_st.song_count = 0; + if (g_list) { + meow_listview_set_count(g_list, g_st.song_count); + 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) { + snprintf(g_title, sizeof g_title, "%s", g_st.playlists[g_st.active_playlist].name); + meow_topappbar_set_title(g_tb, g_title); + } + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->bg); + meow_ui_paint_full(); +} + +void playlist_open(int idx) +{ + if (idx < 0 || idx >= g_st.playlist_count) return; + g_st.active_playlist = idx; + g_st.playing_song = -1; + cover_cache_reset(); + meow_view_page_push(ACT_PLAYLIST); + playlist_refresh(); /* page_enter 也会调用(幂等);这里先拉避免空帧 */ +} + +int playlist_is_active(void) { return g_pc.active; } + +int playlist_on_tap(int x, int y) +{ + if (g_tb) { + int ah = meow_topappbar_hit(g_tb, x, y); + if (ah == MEOW_TOPBAR_BACK) { pl_drag_reset(); meow_view_page_pop(); return 1; } + if (ah >= 0) { pl_drag_reset(); playlist_refresh(); return 1; } + } + if (g_load_err != 0 || g_st.song_count == 0) return 0; + if (meow_widget_hit(g_list, x, y)) { + int row = meow_listview_hit(g_list, x, y); + if (row < 0) { pl_drag_reset(); return 1; } + pl_drag_reset(); + player_play_song(row); /* 起播并推播放页 */ + return 1; + } + return 0; +} + +void playlist_touch_down(int x, int y) +{ + pl_drag_reset(); + if (g_load_err != 0 || g_st.song_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; meow_listview_set_sel(g_list, 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 playlist_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; meow_listview_set_sel(g_list, -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 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; + 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); +} + +static void pl_page_create(meow_view *v, void *ctx) +{ + (void)v; + ((playlist_ctrl *)ctx)->view = v; + r_cont = ncm_content_rect(); + r_list = NR(r_cont.x, r_cont.y, r_cont.w, r_cont.h - APP_FOOTER_H); + if (r_list.h < 0) r_list.h = 0; + meow_ui_activity_push(ACT_PLAYLIST); /* owner=ACT_PLAYLIST */ + g_tb = ncm_topbar_create("Playlist", 1, NULL); + g_list = meow_listview_create(r_list.x, r_list.y, r_list.w, r_list.h, 0, + pl_src2, NULL, MEOW_LIST_SEL_SINGLE); + meow_listview_set_item_h(g_list, APP_ROW_H); + meow_ui_activity_pop(); + g_empty_id = meow_draw_custom_register(1, r_list.x, r_list.y, r_list.w, r_list.h, + pl_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, pl_draw_footer, NULL); + pl_drag_reset(); +} + +static void pl_page_enter(meow_view *v, void *ctx) +{ + (void)v; + ((playlist_ctrl *)ctx)->active = 1; + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->bg); + playlist_refresh(); +} + +static void pl_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((playlist_ctrl *)ctx)->active = 0; +} + +static void pl_page_destroy(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + 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 *playlist_view_create(void) +{ + static const meow_view_hooks h = { + .on_create = pl_page_create, + .on_enter = pl_page_enter, + .on_leave = pl_page_leave, + .on_destroy = pl_page_destroy, + }; + return meow_view_create(ACT_PLAYLIST, MEOW_VIEW_ACTIVITY, &h, &g_pc); +} diff --git a/patch_v2/ncm_app/app/ui/settings.c b/patch_v2/ncm_app/app/ui/settings.c new file mode 100644 index 0000000..096efdd --- /dev/null +++ b/patch_v2/ncm_app/app/ui/settings.c @@ -0,0 +1,193 @@ +/* ui/settings.c — 设置页(ACT_SETTINGS)。 + * + * 行项:音质(Hi-res/较高/标准,默认 Hi-res)+ 缓存上限(1~100GB,默认 10GB)+ 退出登录。 + * 设置持久化到 MeowData/os.meow.ncm/data(meow_config_*;缓存上限键 ncm.cache_limit_gb 与核心库 + * ncm_cache_load_setting 对齐)。音质/上限经 BottomSheet 选择;退出登录经 confirm 弹窗。 + * 空态/绘制用 custom 绘制物 + 手动行命中(仅 3 行,无需列表滚动)。 + */ +#include "../ncm_app_internal.h" +#include +#include +#include + +typedef struct { meow_view *view; int active; } settings_ctrl; +static settings_ctrl g_sc; + +static meow_widget *g_tb; +static ncm_rect_t r_cont; +static int g_custom_id; +static int g_row_h; /* 行高 */ + +static const char *level_name(int lv) +{ + switch (lv) { + case NCM_LEVEL_HIRES: return "Hi-Res"; + case NCM_LEVEL_LOSSLESS:return "Lossless"; + case NCM_LEVEL_EXHIGH: return "Exhigh"; + case NCM_LEVEL_HIGHER: return "Higher"; + default: return "Standard"; + } +} + +static void settings_draw(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_SETTINGS) return; + const meow_theme *th = meow_theme_get(); + g_row_h = 60; + int y = r_cont.y + 16; + int w = r_cont.w - 2 * APP_PAD; + int x = APP_PAD; + + /* 行:音质 / 缓存上限 / 退出登录 */ + struct { const char *title; char sub[48]; } rows[3]; + rows[0].title = "Music Quality"; + rows[1].title = "Cache Limit"; + rows[2].title = "Sign Out"; + snprintf(rows[0].sub, sizeof rows[0].sub, "current: %s", level_name(g_st.level)); + snprintf(rows[1].sub, sizeof rows[1].sub, "current: %d GB", g_st.cache_limit_gb); + snprintf(rows[2].sub, sizeof rows[2].sub, "%s", g_st.nickname[0] ? g_st.nickname : "sign out"); + + for (int i = 0; i < 3; i++) { + int yy = y + i * (g_row_h + 8); + meow_ui_fill_rect(x, yy, w, g_row_h, th->surfaceContainer); + uint32_t tcol = (i == 2) ? th->error : th->fg; + uint32_t scol = (i == 2) ? th->error : th->dim; + meow_ui_text(x + APP_PAD, yy + 12, rows[i].title, 20, tcol); + meow_ui_text(x + APP_PAD, yy + 34, rows[i].sub, 20, scol); + /* 右侧箭头 (draw) */ + meow_ui_text_right(x + w - APP_PAD, yy + 18, ">", 28, th->dim); + } +} + +static int row_at(int x, int y) +{ + (void)x; + int r = r_cont.y + 16; + for (int i = 0; i < 3; i++) { + int yy = r + i * (g_row_h + 8); + if (y >= yy && y <= yy + g_row_h) return i; + } + return -1; +} + +int settings_is_active(void) { return g_sc.active; } + +int settings_on_tap(int x, int y) +{ + if (g_tb) { + int ah = meow_topappbar_hit(g_tb, x, y); + if (ah == MEOW_TOPBAR_BACK) { meow_view_page_pop(); return 1; } + if (ah >= 0) { return 1; } + } + if (x < APP_PAD || x > r_cont.w - APP_PAD) return 0; + int row = row_at(x, y); + if (row < 0) return 0; + + if (row == 0) { /* 音质 */ + meow_sheet *s = meow_sheet_create("Music Quality", 0); + meow_sheet_add_item(s, "Hi-Res"); + meow_sheet_add_item(s, "Higher"); + meow_sheet_add_item(s, "Standard"); + int op = meow_sheet_open(s); + meow_sheet_free(s); + if (op >= 0) { + g_st.level = (op == 0) ? NCM_LEVEL_HIRES : (op == 1) ? NCM_LEVEL_HIGHER : NCM_LEVEL_STANDARD; + player_manager_set_quality(g_st.level, g_st.cache_limit_gb); + appstate_save_settings(); + meow_ui_invalidate(r_cont.x, r_cont.y, r_cont.w, r_cont.h); + } + return 1; + } + if (row == 1) { /* 缓存上限 */ + static const int gbs[] = { 1, 2, 5, 10, 20, 30, 50, 100 }; + static const char *labels[] = { "1 GB", "2 GB", "5 GB", "10 GB", + "20 GB", "30 GB", "50 GB", "100 GB" }; + meow_sheet *s = meow_sheet_create("Cache Limit", 0); + for (int i = 0; i < 8; i++) meow_sheet_add_item(s, labels[i]); + int op = meow_sheet_open(s); + meow_sheet_free(s); + if (op >= 0 && op < 8) { + g_st.cache_limit_gb = gbs[op]; + player_manager_set_quality(g_st.level, g_st.cache_limit_gb); + appstate_save_settings(); + meow_ui_invalidate(r_cont.x, r_cont.y, r_cont.w, r_cont.h); + } + return 1; + } + if (row == 2) { /* 退出登录 */ + meow_dialog *d = meow_dialog_create(MEOW_DLG_CONFIRM, "Sign out?"); + const char *msgs[1]; + msgs[0] = "Playback will stop.\nYou'll need to scan a QR code again."; + meow_dialog_set_items(d, msgs, 1, -1); + int yes = meow_dialog_confirm(d); + meow_dialog_free(d); + if (yes) { + appstate_logout(); + meow_view_page_replace(ACT_LOGIN); + } + return 1; + } + return 0; +} + +void settings_swipe(int dir, int dist) { (void)dir; (void)dist; } +void settings_touch_down(int x, int y) { (void)x; (void)y; } +void settings_touch_move(int x, int y) { (void)x; (void)y; } + +void settings_open(void) +{ + g_st.playing_song = -1; + meow_view_page_push(ACT_SETTINGS); +} + +void settings_reload(void) +{ + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->bg); + meow_ui_paint_full(); +} + +static void st_page_create(meow_view *v, void *ctx) +{ + (void)v; + ((settings_ctrl *)ctx)->view = v; + r_cont = ncm_content_rect(); + meow_ui_activity_push(ACT_SETTINGS); /* owner=ACT_SETTINGS */ + g_tb = ncm_topbar_create("Settings", 1, NULL); + meow_ui_activity_pop(); + g_row_h = 60; + g_custom_id = meow_draw_custom_register(1, r_cont.x, r_cont.y, r_cont.w, r_cont.h, + settings_draw, NULL); +} + +static void st_page_enter(meow_view *v, void *ctx) +{ + (void)v; + ((settings_ctrl *)ctx)->active = 1; + settings_reload(); +} + +static void st_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((settings_ctrl *)ctx)->active = 0; +} + +static void st_page_destroy(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + if (g_custom_id) { meow_draw_unregister(g_custom_id); g_custom_id = 0; } + if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } +} + +meow_view *settings_view_create(void) +{ + static const meow_view_hooks h = { + .on_create = st_page_create, + .on_enter = st_page_enter, + .on_leave = st_page_leave, + .on_destroy = st_page_destroy, + }; + return meow_view_create(ACT_SETTINGS, MEOW_VIEW_ACTIVITY, &h, &g_sc); +} diff --git a/patch_v2/ncm_app/build_ncm.sh b/patch_v2/ncm_app/build_ncm.sh new file mode 100755 index 0000000..c9f2d51 --- /dev/null +++ b/patch_v2/ncm_app/build_ncm.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# build_ncm.sh — 构建 os.meow.ncm(网易云 App,SDK framework=1):链 meow_ui.a + 核心库 + minimp3 + mbedtls。 +# 静态 MIPS32r2 (musl sf)。自有代码(app/ + core/)+ core 支撑 vendor 编译;vendor/minimp3/qrcodegen/jsmn 豁免告警。 +# 仅改本 App 目录;编译期不改 SDK(patch_v2/sdk/)、不动 meow_launcherd/。mipsel -Wall -Wextra 0 warning。 +set -e +cd "$(dirname "$0")" +ROOT="$HOME/Project/R3ProII" +CC="$ROOT/.tools/zig-mipsel-cc" +SYSROOT="$ROOT/.tools/mipsel-linux-muslsf-cross/mipsel-linux-muslsf" +OUT="$PWD/out" +mkdir -p "$OUT/app-mipsel" + +INC="-Icore -I../sdk -I../meow_launcherd \ + -Ivendor/mbedtls/include -Ivendor/jsmn -Ivendor/qrcodegen/c -Ivendor/minimp3" +STRICT="-Wall -Wextra -Os -DJSMN_HEADER" + +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" +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/chrome.c app/logic/appstate.c app/logic/lyric.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" + +echo "==> app+core (strict)" +for f in $APP $CORE; do + b=$(basename "$f" .c) + $CC -static -c $STRICT $INC "$f" -o "$OUT/app-mipsel/$b.o" +done + +echo "==> vendor impl (warn-exempt)" +$CC -static -c -Os -I../meow_launcherd -Ivendor/minimp3 app/play/minimp3_impl.c -o "$OUT/app-mipsel/minimp3_impl.o" +$CC -static -c -Os -Ivendor/jsmn core/jsmn_impl.c -o "$OUT/app-mipsel/jsmn_impl.o" +$CC -static -c -Os -Ivendor/qrcodegen/c vendor/qrcodegen/c/qrcodegen.c -o "$OUT/app-mipsel/qrcodegen.o" + +echo "==> link" +MIPS_LIBS="$OUT/mbedtls/mipsel/libmbedtls.a $OUT/mbedtls/mipsel/libmbedx509.a $OUT/mbedtls/mipsel/libmbedcrypto.a" +$CC -Os -static -Wl,--gc-sections -o ncm \ + $OUT/app-mipsel/*.o \ + ../sdk/meow_ui.a "$SYSROOT/lib/libz.a" \ + $MIPS_LIBS -lpthread -lm +ls -la ncm +file ncm +echo +echo "== size ==" +size ncm +echo "(own code -Wall -Wextra: 0 warning enforced above; vendor exempt)" diff --git a/patch_v2/ncm_app/build_ncm_host.sh b/patch_v2/ncm_app/build_ncm_host.sh new file mode 100755 index 0000000..31b464f --- /dev/null +++ b/patch_v2/ncm_app/build_ncm_host.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# build_ncm_host.sh — 宿主编译 os.meow.ncm 真实页面(离屏 480x720),用于 MEOW_UI_DUMP 截图自查。 +# 思路:gcc 编 SDK 源码(-DMEOW_UI_HOST_TEST, meow_font 打桩 meow_ui_global/render) + 应用源码 + +# 宿主核心库对象(out/core-host/*.o) + 宿主 mbedtls libs。 +# 运行:NCM_PAGE=home MEOW_UI_DUMP=/tmp/home.ppm ./ncm_host +cd "$(dirname "$0")" +ROOT="$HOME/Project/R3ProII" +OUT="$PWD/out" +mkdir -p "$OUT/host" +CC_HOST=${CC_HOST:-gcc} + +SDK_DIR="$ROOT/patch_v2/sdk" +MBED="$OUT/mbedtls/host" + +INC="-I. -I../sdk -I../meow_launcherd -Icore \ + -I$SDK_DIR -I$ROOT/patch_v2/meow_launcherd \ + -Ivendor/mbedtls/include -Ivendor/jsmn -Ivendor/qrcodegen/c -Ivendor/minimp3" + +echo "==> host SDK objects" +SDK_SRC="meow_ui_gfx meow_ui_sys meow_ui_theme meow_ui_vol meow_ui_log meow_ui_widget \ + meow_ui_input meow_ui_activity meow_ui_dialog meow_ui_ime meow_ui_draw meow_ui_view \ + meow_ui_img meow_ui_config" +for f in $SDK_SRC; do + $CC_HOST -O2 -c -DMEOW_UI_HOST_TEST -I"$SDK_DIR" -I"$ROOT/patch_v2/meow_launcherd" \ + "$SDK_DIR/$f.c" -o "$OUT/host/sdk_$f.o" || { echo "sdk $f FAILED"; exit 1; } +done +$CC_HOST -O2 -c -DMEOW_UI_HOST_TEST -I"$SDK_DIR" "$SDK_DIR/stb_image.c" -o "$OUT/host/sdk_stb_image.o" || exit 1 +$CC_HOST -O2 -c -I"$SDK_DIR" -I"$ROOT/patch_v2/meow_launcherd" "$SDK_DIR/test_host_stub.c" -o "$OUT/host/sdk_stub.o" || exit 1 + +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/chrome.c app/logic/appstate.c app/logic/lyric.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 + b=$(basename "$f" .c) + $CC_HOST -O2 -c -DMEOW_UI_HOST_TEST -DJSMN_HEADER $INC "$f" -o "$OUT/host/$b.o" || { echo "app $f FAILED"; exit 1; } +done +$CC_HOST -O2 -c -DMEOW_UI_HOST_TEST -DJSMN_HEADER $INC app/test/test_ncm_app.c -o "$OUT/host/app_test.o" || exit 1 + +echo "==> link ncm_host" +$CC_HOST -O2 -o ncm_host \ + "$OUT/host"/*.o \ + out/core-host/*.o \ + -L"$MBED" -lmbedtls -lmbedx509 -lmbedcrypto \ + -lz -lpthread -lm +ls -la ncm_host +file ncm_host +echo "run: NCM_PAGE=home MEOW_UI_DUMP=/tmp/home.ppm ./ncm_host" diff --git a/patch_v2/ncm_app/ncm b/patch_v2/ncm_app/ncm new file mode 100755 index 0000000..fb7818a Binary files /dev/null and b/patch_v2/ncm_app/ncm differ diff --git a/patch_v2/ncm_app/ncm_host b/patch_v2/ncm_app/ncm_host new file mode 100755 index 0000000..51e3b19 Binary files /dev/null and b/patch_v2/ncm_app/ncm_host differ