feat(ncm): 网易云客户端 v1 UI+播放链路 (agent-205) — os.meow.ncm: 登录页(真实unikey二维码+轮询状态机)/我的歌单(listview)/歌单详情/播放页(封面+seek+滚动歌词+竖横屏切换)/设置(音质默认Hi-res+缓存上限默认10G); 播放线程(缓存LRU+minimp3→ALSA UAPI直开hw:0,0); 媒体会话MSG_META上报+PLAYCTL接收(首真实媒体源)
ncm 1e331964 (text 764KB); 0 warning; 宿主离屏6页截图目检+真实QR可扫+qemu-mipsel干净启动+解码446xRT 真机待联调: ALSA时序/803扫码闭环/媒体会话联动/gbk大字库渲染
This commit is contained in:
1 parent
32543ac616
commit
21519ce2e4
23 files changed
+3255
No files matched your search
@@ -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 <signal.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/* logic/appstate.c — 应用全局状态:ctx/登录/歌单/歌曲/设置。仅依赖核心库 + SDK meow_config_*。 */
|
||||
#include "../ncm_app_internal.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -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 <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#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));
|
||||
}
|
||||
@@ -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/<page>.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 <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <pthread.h>
|
||||
#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/<page>.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 */
|
||||
@@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <sound/asound.h>
|
||||
#include <sys/ioctl.h>
|
||||
#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;
|
||||
}
|
||||
@@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* play/minimp3_impl.c — minimp3(x) 唯一实现 TU(header-only:define 实现一次)。
|
||||
* 编译时 -I<vendor>/minimp3。宿主与 mipsel 共用。 */
|
||||
#define MINIMP3_IMPLEMENTATION
|
||||
#include "minimp3_ex.h"
|
||||
@@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#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;
|
||||
}
|
||||
}
|
||||
@@ -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 <path.mp3>
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#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;
|
||||
}
|
||||
@@ -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_<page>.ppm ./ncm_host
|
||||
* 不依赖 /dev/fb0(SDK 宿主导出 480x720 离屏);meow_font 打桩(ASCII 5x7;中文不渲染,仅验布局)。
|
||||
* 数据全 mock(g_host_mock=1,页面跳过网络)。
|
||||
*/
|
||||
#include "../ncm_app_internal.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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 <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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 <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <math.h>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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 <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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 <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user