Compare commits

...
4 Commits
13 changed files with 1393 additions and 31 deletions

No files matched your search

+282 -25
View File
@@ -98,6 +98,13 @@ static char g_net_policy[160]= "/data/meow/netmgrd/net_policy.conf"; /* MEOW_NET
#define EVT_DUP_MAX 64 /* 事件日志 1 分钟去重表 */
#define DNS_RESOLV_CHECK_SEC 5 /* resolv.conf 自检周期 */
/* ---- 期 5 常量(公网流量统计:netlink sock_diag 按 TCP 连接累计字节;静态表,禁 malloc) ---- */
#define FLOW_LAST_MAX 128 /* inode→上拍累计 跟踪表(仅公网/内网 TCP 流) */
#define PUB_FLOW_MAX 16 /* 公网字节 top 流表(内存累加用) */
#define PUB_SERVE_ENT 6 /* status JSON 序列化的 top 实体数(4096 帧预算内) */
#define PUB_SERVE_FLOW 6 /* status JSON 序列化的 top 流数 */
#define TCP_INFO_MIN_LEN 136 /* tcp_info 载荷≥此值才含 tcpi_bytes_received(off128+8) */
#define DNS_PORT_DEF 53 /* 监听端口(MEOW_DNS_PORT 覆盖;宿主测试用高位规避 <1024 权限) */
#define DNS_SELF_IP "127.0.0.1"
@@ -402,30 +409,19 @@ static int netdev_iface(const char *ifname, long *rx, long *tx)
return found;
}
/* 全局 /proc/net/dev RX/TX 累计(所有接口求和)。 */
static void netdev_totals(long *rx, long *tx)
/* 上行接口累计字节(/proc/net/dev)。仅取「非回环且 up」接口求和——本设备即 wlan0;去掉 lo 对
* 实时速率的虚高污染。依赖调用方先 enum_ifaces(0) 刷新 g_ifaces[] 再取数(daemon_tick 节拍内如此)。
* 无任何 up 非回环接口 → rx 置 -1(速率未知/无连接,不谎报 0)。 */
static void uplink_totals(long *rx, long *tx)
{
long trx = 0, ttx = 0;
FILE *f = fopen(g_netdev, "r");
if (f) {
char line[256];
while (fgets(line, sizeof line, f)) {
char *c = strchr(line, ':');
if (!c) continue;
char *save = NULL;
char *tk = strtok_r(c + 1, " \t", &save);
long vr = 0;
if (tk) {
vr = strtol(tk, NULL, 10);
for (int i = 0; i < 8; i++) tk = strtok_r(NULL, " \t", &save);
if (tk) ttx += strtol(tk, NULL, 10);
long trx = 0, ttx = 0, seen = 0;
for (int i = 0; i < g_iface_n; i++) {
if (iface_parse_kind(g_ifaces[i].name) == 0) continue; /* lo */
if (!g_ifaces[i].up) continue; /* 只取 up */
trx += g_ifaces[i].rx; ttx += g_ifaces[i].tx; seen = 1;
}
trx += vr;
}
fclose(f);
}
if (rx) *rx = trx;
if (tx) *tx = ttx;
if (rx) *rx = seen ? trx : -1;
if (tx) *tx = seen ? ttx : 0;
}
/* iface IP:优先 `ip -o -4 addr show dev <if>`;ip 缺失/无输出 → fib_trie 兜底(作用于主口)。
@@ -842,6 +838,24 @@ static char g_prev_if[IFACE_MAX][32];
static int g_prev_if_up[IFACE_MAX];
static int g_prev_if_n = 0;
/* ---- 期 5:公网/内网流量统计(netlink sock_diag,按 TCP 连接累计字节;UDP 无字节计数,诚实标注)。
* 全部静态表(禁 malloc);每 2s 节拍在 census_run 内刷新,build_status_json 只读序列化。 ---- */
typedef struct { char entity[48]; uint64_t pub_rx, pub_tx; /* 远端为公网的 TCP 累计 */
uint64_t lan_rx, lan_tx; } pub_ent_t; /* 远端为内网/本链路的 TCP 累计 */
static pub_ent_t g_pub_ent[ENTITY_MAX];
static int g_pub_ent_n = 0;
typedef struct { uint32_t inode; uint64_t last_rx, last_tx; long seen_gen; } flow_last_t; /* inode→上拍累计(增量用) */
static flow_last_t g_flow_last[FLOW_LAST_MAX];
static int g_flow_last_n = 0;
static long g_flow_gen = 0; /* 每拍代际(剔陈旧 inode 用) */
typedef struct { uint32_t remote_ip; uint16_t remote_port; char entity[48]; char domain[48];
uint64_t rx, tx; long last_seen; } pub_flow_t; /* 公网字节 top 流 */
static pub_flow_t g_pub_flow[PUB_FLOW_MAX];
static int g_pub_flow_n = 0;
static uint64_t g_pub_rx = 0, g_pub_tx = 0; /* 公网总收发累计(TCP) */
static int g_diag_fd = -1; /* NETLINK_SOCK_DIAG(懒开) */
static int g_diag_warned = 0; /* 打开失败仅告警一次 */
/* ---------------- JSON 构建:status/iface ---------------- */
static void iface_json_obj(jw_t *w, const iface_t *f)
{
@@ -852,6 +866,80 @@ static void iface_json_obj(jw_t *w, const iface_t *f)
jadd(w, "\"rx_bytes\":%ld,\"tx_bytes\":%ld}", f->rx, f->tx);
}
/* status JSON 里的公网/内网流量子块。TOP-K 序列化保住 4096 单帧预算;数据侧仍全量累计。
* 口径:netlink sock_diag 按 TCP 连接(远端分公网/内网)累计字节;UDP/ARP/mDNS 无字节计数,标 tcp_only。
* 注意:本函数只闭合 pub_bytes 对象(}, 不闭合外层 status 对象,由调用方收尾)。 */
static void pub_bytes_json(jw_t *w)
{
jadd(w, ",\"pub_bytes\":{");
jadd(w, "\"source\":\"sock_diag_tcp\",\"tcp_only\":true,");
jadd(w, "\"total\":{\"rx\":%llu,\"tx\":%llu},", (unsigned long long)g_pub_rx, (unsigned long long)g_pub_tx);
/* by_entity:按 pub+lan total 取 TOP-K(量小,线性选择)。 */
jadd(w, "\"by_entity\":[");
{
int sel[PUB_SERVE_ENT], seln = 0;
for (int i = 0; i < g_pub_ent_n; i++) {
uint64_t s = g_pub_ent[i].pub_rx + g_pub_ent[i].pub_tx +
g_pub_ent[i].lan_rx + g_pub_ent[i].lan_tx;
if (seln < PUB_SERVE_ENT) { sel[seln++] = i; continue; }
int mi = 0;
for (int j = 1; j < seln; j++) {
pub_ent_t *a = &g_pub_ent[sel[j]];
pub_ent_t *b = &g_pub_ent[sel[mi]];
if (a->pub_rx + a->pub_tx + a->lan_rx + a->lan_tx <
b->pub_rx + b->pub_tx + b->lan_rx + b->lan_tx) mi = j;
}
pub_ent_t *m = &g_pub_ent[sel[mi]];
if (m->pub_rx + m->pub_tx + m->lan_rx + m->lan_tx < s) sel[mi] = i;
}
for (int i = 0; i < seln; i++) for (int j = i + 1; j < seln; j++) {
pub_ent_t *a = &g_pub_ent[sel[i]], *b = &g_pub_ent[sel[j]];
if (a->pub_rx + a->pub_tx + a->lan_rx + a->lan_tx <
b->pub_rx + b->pub_tx + b->lan_rx + b->lan_tx) { int t = sel[i]; sel[i] = sel[j]; sel[j] = t; }
}
for (int k = 0; k < seln; k++) {
pub_ent_t *e = &g_pub_ent[sel[k]];
if (k) jadd(w, ",");
jadd(w, "{\"entity\":\"%s\",\"pub\":[%llu,%llu],\"lan\":[%llu,%llu]}",
e->entity, (unsigned long long)e->pub_rx, (unsigned long long)e->pub_tx,
(unsigned long long)e->lan_rx, (unsigned long long)e->lan_tx);
}
}
jadd(w, "],");
/* by_flow:仅公网 top 流。 */
jadd(w, "\"by_flow\":[");
{
int sel[PUB_SERVE_FLOW], seln = 0;
for (int i = 0; i < g_pub_flow_n; i++) {
uint64_t s = g_pub_flow[i].rx + g_pub_flow[i].tx;
if (seln < PUB_SERVE_FLOW) { sel[seln++] = i; continue; }
int mi = 0;
for (int j = 1; j < seln; j++)
if (g_pub_flow[sel[j]].rx + g_pub_flow[sel[j]].tx <
g_pub_flow[sel[mi]].rx + g_pub_flow[sel[mi]].tx) mi = j;
if (g_pub_flow[sel[mi]].rx + g_pub_flow[sel[mi]].tx < s) sel[mi] = i;
}
for (int i = 0; i < seln; i++) for (int j = i + 1; j < seln; j++)
if (g_pub_flow[sel[i]].rx + g_pub_flow[sel[i]].tx <
g_pub_flow[sel[j]].rx + g_pub_flow[sel[j]].tx) { int t = sel[i]; sel[i] = sel[j]; sel[j] = t; }
for (int k = 0; k < seln; k++) {
pub_flow_t *f = &g_pub_flow[sel[k]];
char ip[32];
snprintf(ip, sizeof ip, "%u.%u.%u.%u",
(f->remote_ip >> 24) & 0xff, (f->remote_ip >> 16) & 0xff,
(f->remote_ip >> 8) & 0xff, f->remote_ip & 0xff);
if (k) jadd(w, ",");
jadd(w, "{\"ip\":\"%s\",\"port\":%u,\"entity\":\"%s\",\"domain\":\"%s\",\"rx\":%llu,\"tx\":%llu}",
ip, (unsigned)f->remote_port, f->entity, f->domain,
(unsigned long long)f->rx, (unsigned long long)f->tx);
}
}
jadd(w, "]");
jadd(w, "}");
}
static void build_status_json(char *out, size_t cap)
{
jw_t w = { out, cap, 0 };
@@ -918,7 +1006,10 @@ static void build_status_json(char *out, size_t cap)
jadd(&w, "{\"entity\":\"%s\",\"rx\":%u,\"tx\":%u}", g_ent_bytes[i].entity,
g_ent_bytes[i].rx, g_ent_bytes[i].tx);
}
jadd(&w, "]}");
jadd(&w, "]");
/* 期5:公网/内网流量(netlink sock_diag)——只闭合 pub_bytes 对象,外层 status 由下方 `}` 收尾。 */
pub_bytes_json(&w);
jadd(&w, "}");
}
/* 单/全接口详情 JSON。name 空=全部(数组);否则单对象。返回 MEOW_OK / MEOW_ERR_NOENT。 */
@@ -1549,6 +1640,170 @@ static void evt_conn_log(const char *entity, const char *dst, const char *what)
net_log("info", "conn", "%s entity=%s dst=%s", what, entity, dst);
}
/* ---- 期5:公网/内网流量计量(netlink sock_diag,per-TCP 连接累计字节) ----
* 口径:每个 TCP 连接 tcp_info 的 tcpi_bytes_received(off128)/tcpi_bytes_acked(off120) 是连接
* 生命周期累计;按拍差分(首见=基线 0,防一次性计入存量),远端分公网/内网(RFC1918/回环/链路本地/
* 组播都归内网)。UDP 无字节计数(DNS/流媒体 UDP 不计量),故整体标 tcp_only。内核 4.4.94 实测返回
* tcp_info plen=144,bytes_* 有效;所有表静态,禁 malloc。 */
static int ip_is_public(uint32_t ip)
{
uint8_t b0 = (uint8_t)(ip >> 24), b1 = (uint8_t)(ip >> 16);
if (b0 == 0 || b0 == 127) return 0; /* 保留/回环 */
if (b0 == 10) return 0; /* RFC1918 10/8 */
if (b0 == 172 && b1 >= 16 && b1 <= 31) return 0; /* RFC1918 172.16/12 */
if (b0 == 192 && b1 == 168) return 0; /* RFC1918 192.168/16 */
if (b0 == 169 && b1 == 254) return 0; /* 链路本地 */
if (b0 >= 224) return 0; /* 组播/保留 224/4+240/4 */
return 1;
}
static flow_last_t *flow_last_get(uint32_t inode)
{
for (int i = 0; i < g_flow_last_n; i++)
if (g_flow_last[i].inode == inode) return &g_flow_last[i];
if (g_flow_last_n < FLOW_LAST_MAX) return &g_flow_last[g_flow_last_n++];
return &g_flow_last[0]; /* 全满:复用首槽(本设备并发 TCP 极少,防御性兜底) */
}
static pub_ent_t *pub_ent_get(const char *entity)
{
for (int i = 0; i < g_pub_ent_n; i++)
if (strcmp(g_pub_ent[i].entity, entity) == 0) return &g_pub_ent[i];
if (g_pub_ent_n < ENTITY_MAX) {
pub_ent_t *p = &g_pub_ent[g_pub_ent_n++];
memset(p, 0, sizeof *p);
snprintf(p->entity, sizeof p->entity, "%s", entity);
return p;
}
return &g_pub_ent[0]; /* 满:并入首槽(保稳定,不新键) */
}
static void pub_flow_add(uint32_t rip, uint16_t rport, const char *entity,
const char *domain, uint64_t drx, uint64_t dtx)
{
if (drx == 0 && dtx == 0) return;
pub_flow_t *f = NULL;
for (int i = 0; i < g_pub_flow_n; i++)
if (g_pub_flow[i].remote_ip == rip && g_pub_flow[i].remote_port == rport &&
strcmp(g_pub_flow[i].entity, entity) == 0) { f = &g_pub_flow[i]; break; }
if (!f) {
if (g_pub_flow_n < PUB_FLOW_MAX) f = &g_pub_flow[g_pub_flow_n++];
else {
f = &g_pub_flow[0];
for (int i = 1; i < PUB_FLOW_MAX; i++)
if (g_pub_flow[i].rx + g_pub_flow[i].tx < f->rx + f->tx) f = &g_pub_flow[i];
}
memset(f, 0, sizeof *f);
f->remote_ip = rip; f->remote_port = rport;
snprintf(f->entity, sizeof f->entity, "%s", entity);
snprintf(f->domain, sizeof f->domain, "%s", domain ? domain : "");
}
f->rx += drx; f->tx += dtx; f->last_seen = mono_now();
}
static void pub_bytes_run(const conn_t *conns, int n)
{
/* 懒开 NETLINK_SOCK_DIAG。 */
if (g_diag_fd < 0) {
g_diag_fd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_SOCK_DIAG);
if (g_diag_fd < 0) {
if (!g_diag_warned) { g_diag_warned = 1; net_log("warn", "pub", "sock_diag open fail errno=%d", errno); }
return;
}
struct timeval tv = { 0, 120000 }; /* 120ms 收超时,防单拍卡死(本设备连接极少,秒回) */
setsockopt(g_diag_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
struct sockaddr_nl sa; memset(&sa, 0, sizeof sa);
sa.nl_family = AF_NETLINK;
bind(g_diag_fd, (struct sockaddr *)&sa, sizeof sa);
}
struct { struct nlmsghdr nlh; struct inet_diag_req_v2 req; } req;
memset(&req, 0, sizeof req);
req.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(req.req));
req.nlh.nlmsg_type = SOCK_DIAG_BY_FAMILY;
req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP;
req.req.sdiag_family = AF_INET;
req.req.sdiag_protocol = IPPROTO_TCP;
req.req.idiag_ext = (1u << (INET_DIAG_INFO - 1)); /* 请求 tcp_info 扩展 */
req.req.idiag_states = 0xFFFFFFFF;
struct sockaddr_nl kern; memset(&kern, 0, sizeof kern);
kern.nl_family = AF_NETLINK;
if (sendto(g_diag_fd, &req, req.nlh.nlmsg_len, 0, (struct sockaddr *)&kern, sizeof kern) < 0) {
if (!g_diag_warned) { g_diag_warned = 1; net_log("warn", "pub", "sock_diag send fail errno=%d", errno); }
return;
}
g_flow_gen++;
char buf[16384];
int done = 0;
for (int round = 0; round < 8 && !done; round++) { /* 有界收割 */
ssize_t r = recv(g_diag_fd, buf, sizeof buf, 0);
if (r < 0) break; /* 超时/错误 → 结束本拍 */
for (struct nlmsghdr *nlh = (struct nlmsghdr *)buf; NLMSG_OK(nlh, (unsigned)r);
nlh = NLMSG_NEXT(nlh, r)) {
if (nlh->nlmsg_type == NLMSG_DONE || nlh->nlmsg_type == NLMSG_ERROR) { done = 1; break; }
struct inet_diag_msg *dm = (struct inet_diag_msg *)NLMSG_DATA(nlh);
uint32_t inode = dm->idiag_inode;
uint32_t rnet = ntohl(dm->id.idiag_dst[0]); /* 远端(host 序) */
uint16_t rport = ntohs(dm->id.idiag_dport);
if (rnet == 0 || rport == 0 || inode == 0) continue; /* 监听口/未定 → 跳过 */
int msglen = (int)(nlh->nlmsg_len - NLMSG_LENGTH(sizeof(struct inet_diag_msg)));
if (msglen <= 0) continue;
/* 解析 INET_DIAG_INFO(tcp_info),取 bytes_received(off128)/bytes_acked(off120)。 */
uint64_t brx = 0, btx = 0;
int have = 0;
struct nlattr *at = (struct nlattr *)((char *)dm + sizeof(struct inet_diag_msg));
int rem = msglen;
while (rem >= (int)sizeof(struct nlattr) && at->nla_len >= sizeof(struct nlattr) &&
at->nla_len <= (unsigned)rem) {
int type = at->nla_type & NLA_TYPE_MASK;
int plen = (int)at->nla_len - NLA_HDRLEN;
if (type == INET_DIAG_INFO && plen >= TCP_INFO_MIN_LEN) {
unsigned char *pay = (unsigned char *)at + NLA_HDRLEN;
memcpy(&brx, pay + 128, 8); /* tcpi_bytes_received */
memcpy(&btx, pay + 120, 8); /* tcpi_bytes_acked */
have = 1;
break;
}
int alen = NLA_ALIGN((int)at->nla_len);
rem -= alen;
at = (struct nlattr *)((char *)at + alen);
}
if (!have) continue;
/* 匹配连接表 → 实体 + 域名;未归因 → 跳过(保归因干净)。 */
const conn_t *c = NULL;
for (int i = 0; i < n; i++) if (conns[i].inode == inode) { c = &conns[i]; break; }
if (!c) continue;
flow_last_t *fl = flow_last_get(inode);
uint64_t drx, dtx;
if (fl->inode != inode) { /* 首见/复槽 → 基线扣 0,防一次性计入存量 */
fl->inode = inode;
fl->last_rx = brx; fl->last_tx = btx;
fl->seen_gen = g_flow_gen;
drx = 0; dtx = 0;
} else {
drx = brx >= fl->last_rx ? (brx - fl->last_rx) : brx;
dtx = btx >= fl->last_tx ? (btx - fl->last_tx) : btx;
fl->last_rx = brx; fl->last_tx = btx;
fl->seen_gen = g_flow_gen;
}
if (drx == 0 && dtx == 0) continue;
pub_ent_t *pe = pub_ent_get(c->entity);
if (ip_is_public(rnet)) {
pe->pub_rx += drx; pe->pub_tx += dtx;
g_pub_rx += drx; g_pub_tx += dtx;
pub_flow_add(rnet, rport, c->entity, c->domain, drx, dtx);
} else {
pe->lan_rx += drx; pe->lan_tx += dtx;
}
}
}
/* 剔掉本拍未出现的 inode(已关闭/变监听口),防表膨胀。 */
if (g_flow_last_n) {
int w = 0;
for (int i = 0; i < g_flow_last_n; i++)
if (g_flow_last[i].seen_gen == g_flow_gen) g_flow_last[w++] = g_flow_last[i];
g_flow_last_n = w;
}
}
/* ---- 连接普查(每 2s):/proc/net/{tcp,udp} → inode→pid→实体 → 连接表 + 事件 ---- */
static void census_run(void)
{
@@ -1602,6 +1857,8 @@ static void census_run(void)
evt_conn_log(g_conn[j].entity[0] ? g_conn[j].entity : "proc:unknown", dst, "close");
}
}
/* 期5:公网/内网流量计量(netlink sock_diag,基于本拍连接表归因)。 */
pub_bytes_run(g_conn, g_conn_n);
}
/* ---- DNS 代理(§5;三条兜底:双行 resolv + atexit 恢复 + 5s 自检) ---- */
@@ -1976,9 +2233,9 @@ static void daemon_tick(void)
}
g_prev_if_n = cur;
/* 全局速率差分(2s 拍):/proc/net/dev 累计。 */
/* 全局速率差分(2s 拍):仅上行接口累计(去 lo)。 */
long rx = -1, tx = 0;
netdev_totals(&rx, &tx);
uplink_totals(&rx, &tx);
if (rx >= 0) {
long now = mono_now();
if (g_net_last_rx >= 0 && now > g_net_last_mono) {
+43 -1
View File
@@ -747,6 +747,12 @@
} else {
rows += homeKv('DNS', '<span class="home-chip mut">—</span>');
}
const pb = (nd && nd.pub_bytes) || null;
if (pb && pb.total) {
rows += homeKv('公网流量', '↓ ' + fmtBytes(pb.total.rx || 0) + ' · ↑ ' + fmtBytes(pb.total.tx || 0) + '<span class="muted">(TCP)</span>');
} else {
rows += homeKv('公网流量', '<span class="home-chip mut">—</span>');
}
return homeCard('WiFi / DNS', '<div class="home-card-grid">' + rows + '</div>');
}
function healthCard() {
@@ -1269,6 +1275,7 @@
meta += (ifd.rssi != null) ? '信号 ' + esc(ifd.rssi) + ' dBm' : '信号 —';
if (ifd.dns && ifd.dns.length) meta += ' · DNS ' + esc(ifd.dns.join(' / '));
meta += '</div>';
h += '<p class="muted" style="margin-bottom:8px">RX / TX 为接口累计字节(含局域网/面板轮询/DNS 等内网往返),非手机热点公网消耗。</p>';
h += meta;
h += '<table class="table"><thead><tr><th>接口</th><th>类型</th><th>状态</th><th>IP 地址</th><th>网关</th><th>RX / TX</th></tr></thead><tbody>';
if (!ifs.length) h += '<tr><td colspan="6" class="empty">无接口数据</td></tr>';
@@ -1289,11 +1296,46 @@
h += '</div>';
return h;
}
/* 公网流量卡(netmgrd pub_bytes:netlink sock_diag 按 TCP 连接累积,远端公网/内网分流)。
* 口径诚实:仅 TCP;UDP/DNS/mDNS/ARP 不计;内网本链路(面板轮询等)归 lan。 */
function pubBytesCard(pb) {
if (!pb) return '';
const t = pb.total || {};
let h = '<div class="card"><h3 class="card-title">公网流量</h3>';
h += '<p class="muted">按 TCP 连接计量(远端公网才计入);UDP/DNS/mDNS/ARP 不计。接口累计含内网往返,非公网消耗。</p>';
h += '<div class="home-sub" style="margin-bottom:8px">公网累计:↓ ' + fmtBytes(t.rx || 0) + ' · ↑ ' + fmtBytes(t.tx || 0) + '</div>';
const ents = pb.by_entity || [];
if (ents.length) {
h += '<table class="table"><thead><tr><th>实体</th><th>公网 ↓ / ↑</th><th>内网 ↓ / ↑</th></tr></thead><tbody>';
ents.forEach(e => {
h += '<tr><td class="net-entity">' + netBadge(e.entity) + '</td>' +
'<td class="muted">' + fmtBytes((e.pub && e.pub[0]) || 0) + ' / ' + fmtBytes((e.pub && e.pub[1]) || 0) + '</td>' +
'<td class="muted">' + fmtBytes((e.lan && e.lan[0]) || 0) + ' / ' + fmtBytes((e.lan && e.lan[1]) || 0) + '</td></tr>';
});
h += '</tbody></table>';
}
const flows = pb.by_flow || [];
if (flows.length) {
h += '<div class="home-sub" style="margin-top:10px">公网流 Top</div>';
h += '<table class="table"><thead><tr><th>远端</th><th>实体</th><th>↓ / ↑</th></tr></thead><tbody>';
flows.forEach(f => {
h += '<tr><td class="mono">' + esc(f.ip || '') + ':' + esc('' + f.port) +
(f.domain ? ' <span class="muted">' + esc(f.domain) + '</span>' : '') + '</td>' +
'<td class="net-entity">' + netBadge(f.entity) + '</td>' +
'<td class="muted">' + fmtBytes(f.rx || 0) + ' / ' + fmtBytes(f.tx || 0) + '</td></tr>';
});
h += '</tbody></table>';
}
h += '</div>';
return h;
}
/* WiFi / 蓝牙摘要(/api/net/status:E0 概览,含实时速率)。接口细分已迁 netIfaceCards。 */
function netStatusCards(d) {
let h = '';
if (d && d.rate && (d.rate.rx_bps != null || d.rate.tx_bps != null))
h += '<div class="card"><p class="muted">实时速率:↓ ' + fmtBytes(d.rate.rx_bps) + '/s · ↑ ' + fmtBytes(d.rate.tx_bps) + '/s</p></div>';
h += '<div class="card"><p class="muted">实时速率(上行接口,不含回环):↓ ' + fmtBytes(d.rate.rx_bps) + '/s · ↑ ' + fmtBytes(d.rate.tx_bps) + '/s</p></div>';
if (d && d.pub_bytes) h += pubBytesCard(d.pub_bytes);
// WiFi 摘要
const w = (d && d.wifi) || {};
h += '<div class="card"><h3 class="card-title">WiFi</h3><table class="table"><tbody>';
+5
View File
@@ -86,6 +86,8 @@ static void app_on_key(int keycode, int is_down)
static void app_on_tap(int x, int y)
{
/* mini 播放栏:非播放页底部常驻,顶层先拦(点钮 toggle / 点栏体进播放页),不落页面 on_tap。 */
if (mini_bar_hit(x, y)) return;
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; }
@@ -158,6 +160,7 @@ static void app_on_tick(void)
if (act != g_reported_act) { g_reported_act = act; ipc_act_report(act); }
}
uint64_t now = meow_ui_time_ms();
mini_bar_tick(now); /* mini 播放栏:可见性/歌曲/暂停态/进度变化 → 失效栏带并重绘 */
/* §NEW cookie 续期判会话失效 → 清登录态回登录页(worker 在后台续期发现 301/-460 时置标志;
* 在此 UI 线程执行 logout+切页——meow_view_page_replace 非线程安全,只能 UI 线程做) */
if (account_cookie_need_relogin()) {
@@ -223,6 +226,7 @@ int main(void)
g_plr_view = player_view_create();
g_set_view = settings_view_create();
g_search_view = search_view_create();
mini_bar_init(); /* mini 播放栏:登记跨 Activity 绘制物(进 root 页前,首帧即参与重画) */
/* 进入根页:已登录→home,否则→login */
meow_view_page_push(g_st.logged_in ? ACT_HOME : ACT_LOGIN);
@@ -237,6 +241,7 @@ int main(void)
g_app_quit = 1;
player_manager_stop_thread();
ipc_media_stop();
mini_bar_shutdown();
meow_view_free(g_set_view);
meow_view_free(g_plr_view);
meow_view_free(g_pl_view);
+6
View File
@@ -349,6 +349,12 @@ void settings_touch_move(int x, int y);
void settings_open(void); /* 进设置页 */
void settings_reload(void);
/* mini 播放栏(ui/minibar.c,跨 Activity 常驻;非播放页 + 有活动播放会话时显示)*/
void mini_bar_init(void); /* 登记 CUSTOM 绘制物(main 建页后调) */
void mini_bar_shutdown(void); /* 注销(app 退出前) */
void mini_bar_tick(uint64_t now);/* 每帧:可见性/歌曲/暂停态/进度变化 → 失效栏带并重绘 */
int mini_bar_hit(int x, int y); /* app_on_tap 顶层先行拦截:1=消费(toggle/进播放页) */
/* search */
meow_view *search_view_create(void);
int search_is_active(void);
+42 -1
View File
@@ -95,6 +95,9 @@ static int page_id(const char *name)
if (strcmp(name, "grid") == 0) return ACT_PLAYER; /* 保留:旧横屏(重做后恒竖屏) */
if (strcmp(name, "playerbuf") == 0) return ACT_PLAYER; /* §18 缓冲态 */
if (strcmp(name, "playererr") == 0) return ACT_PLAYER; /* §18 错误态 */
if (strcmp(name, "plsplay") == 0) return ACT_PLAYLIST; /* mini 栏目检(非播放页+播放态) */
if (strcmp(name, "homeplay") == 0) return ACT_HOME; /* mini 栏目检(首页+播放态) */
if (strcmp(name, "barhit") == 0) return ACT_PLAYLIST; /* mini 栏命中自检 */
if (strcmp(name, "settings") == 0) return ACT_SETTINGS;
if (strcmp(name, "search") == 0) return ACT_SEARCH;
if (strcmp(name, "searchhist") == 0) return ACT_SEARCH; /* §23 空态=历史列表 */
@@ -123,6 +126,7 @@ int main(int argc, char **argv)
g_dark = dark;
meow_theme_add_listener(ncm_on_theme);
ncm_on_theme(dark ? MEOW_THEME_DARK : MEOW_THEME_LIGHT); /* 同步初始 g_dark */
mini_bar_init(); /* mini 播放栏:登记跨 Activity 绘制物(真实页面 + 真实 SDK 绘制物注册表) */
/* mock 状态 */
if (g_st.cache_dir[0] == 0) {
@@ -229,6 +233,18 @@ int main(int argc, char **argv)
appstate_search_history_add("Jay \u5553\u8868");
appstate_search_history_add("lofi beat");
appstate_search_history_add("Jay \u5553\u8868"); /* 去重 → 仍 3 条 */
} else if (strcmp(name, "plsplay") == 0 || strcmp(name, "homeplay") == 0 ||
strcmp(name, "barhit") == 0) {
/* mini 播放栏目检/命中自检:非播放页(歌单/首页)+ 播放态 → 底部显示 mini 栏 */
mock_playlists(); mock_songs();
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;
g_play.play_song = g_st.songs[0]; /* 播放快照(§17) */
g_play.play_song_valid = 1;
pthread_mutex_unlock(&g_play.mu);
} else {
mock_playlists();
}
@@ -236,7 +252,8 @@ int main(int argc, char **argv)
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, "playlist") == 0 || strcmp(name, "plsplay") == 0 ||
strcmp(name, "barhit") == 0) v = playlist_view_create();
else if (page_id(name) == ACT_PLAYER) v = player_view_create(); /* player/lands/playerbuf/playererr */
else if (strcmp(name, "settings") == 0) v = settings_view_create();
else if (strcmp(name, "search") == 0 || strcmp(name, "searchhist") == 0 ||
@@ -277,6 +294,8 @@ int main(int argc, char **argv)
p0, p1, p2, latched_change, ok ? "PASS" : "FAIL");
if (!ok) return 1;
}
/* 任意页先喂一次 mini 栏 tick:可见性/歌曲/进度变化 → 失效栏带,随后整帧重画包含 mini 栏 */
mini_bar_tick(meow_ui_time_ms());
/* player 页:其 on_tick 通常驱动进度;这里直接再画一次确保状态就绪 */
meow_ui_paint_full();
if (page_id(name) == ACT_PLAYER) {
@@ -316,6 +335,28 @@ int main(int argc, char **argv)
(ok1 && ok2) ? "PASS" : "FAIL");
if (!(ok1 && ok2)) return 1;
}
if (strcmp(name, "barhit") == 0) {
/* mini 播放栏命中自检(几何与 minibar.c 同口径:W=480 H=720 → 栏带 (0,656,480,64),
* 播放钮中心 (446,688),命中半径 22;栏体外 (100,600) 不消费):
* ① 播放态下点播放钮 → 暂停;再点 → 恢复(toggle);
* ② 点栏体 → 消费并 push(ACT_PLAYER)(进播放页);进播放页后 active=ACT_PLAYER → 栏隐藏(不消费);
* ③ 点播放钮回到非播放页后,栏体外点按不消费(0,落页面 on_tap)。 */
int w = APP_W, h = APP_H, bh = 64, by = h - bh;
int bx = w - 12 - 32 / 2 - 12 / 2, bcy = by + 32; /* 与 minibar.c MINI_* 一致 */
pthread_mutex_lock(&g_play.mu); int s0 = g_play.state; pthread_mutex_unlock(&g_play.mu);
int h1 = mini_bar_hit(bx, bcy); /* 播放钮:toggle */
pthread_mutex_lock(&g_play.mu); int s1 = g_play.state; pthread_mutex_unlock(&g_play.mu);
int h2 = mini_bar_hit(bx, bcy); /* 再点:toggle 回 */
pthread_mutex_lock(&g_play.mu); int s2 = g_play.state; pthread_mutex_unlock(&g_play.mu);
int ok_tg = (h1 == 1 && s1 == NCM_PC_PAUSED) && (h2 == 1 && s2 == NCM_PC_PLAYING);
int h3 = mini_bar_hit(100, bcy); /* 栏体 → 进播放页 */
int act_after = meow_ui_activity_active(); /* 应为 ACT_PLAYER */
int h4 = mini_bar_hit(100, bcy); /* 已在播放页 → 栏隐藏 → 不消费 */
printf("barhit: toggle %d->%d->%d=%s consume-body=%d act=%d(PLAYER) hidden=%d -> %s\n",
s0, s1, s2, ok_tg ? "PASS" : "FAIL", h3, act_after, h4,
(ok_tg && h3 == 1 && act_after == ACT_PLAYER && h4 == 0) ? "PASS" : "FAIL");
if (!ok_tg) return 1;
}
meow_ui_shutdown();
printf("dumped %s -> %s\n", name, dump);
+224
View File
@@ -0,0 +1,224 @@
/* ui/minibar.c — 底部 mini 播放栏(跨 Activity 常驻;非播放页 + 有活动播放会话时显示)。
*
* 需求(用户定案 2026-09-03):ncm 首页/歌单/搜索/设置等**非播放页**底部,当存在活动播放会话
* (player_manager 有当前曲目,state != NCM_PC_STOPPED)时显示一条紧凑 mini 播放栏(与主流
* 音乐 App 一致):封面缩略(40×40,复用 cover 缩略链)+ 歌名/歌手(截断)+ 播放/暂停钮
* (点击 toggle)+ 细进度带;**点击栏体 → 跳转 ncm 播放页(ACT_PLAYER)**;播放页内不显示此栏。
* 替代原先「顶栏进播放页图标」方案(本栏常驻非播放页,进播放页更直接,与主流 App 交互一致)。
*
* 实现(零侵入页面 UI,跨页面常驻,最小侵):
* - 绘制 = SDK 绘制物注册表 CUSTOM 项(z=MINI_Z,bbox=底部 MINI_H 带)。CUSTOM 项不按 owner
* Activity 过滤(见 meow_ui_draw.c paint():仅 WIDGET 按 owner 过滤、VIEW 按 entered 过滤,
* CUSTOM 恒画)→ 跨 Activity 常驻;且只在它所在区域被失效/整帧重画时被调(区域收敛,不整屏)。
* - 命中 = app.c app_on_tap **顶层先调 mini_bar_hit()**(返回 1 即消费,不再落页面 on_tap)——
* 点播放/暂停钮 toggle、点栏体 push(ACT_PLAYER)。不走 meow_ui_global:宿主 test_host_stub
* 只桩了 global_render/hit 而未桩 add/remove,为双端 0 warning 可链,改走 app_on_tap 拦截。
* - 刷新 = mini_bar_tick()(app_on_tick 每帧调):可见性/歌曲/暂停态/进度 1% 桶变化 →
* 失效整条栏带并重绘;切歌 → 复用 cover_request 拉新封面(非播放页也随切歌更新)。
* - 绘制安全:封面 cover_get() 持锁返回,绘完 cover_put()(与播放/歌单页同口径,§18 修 UAF)。
*
* 性能:栏带仅 ~480×64;只在该带失效时重画(区域收敛),不整屏;进度按 1% 桶驱动
* (约 1 次/2s,不逐帧刷)。播放/进度/元数据读取全部走 player_manager 与 cover(锁内快照),
* 不新开线程、不碰页面布局。
*/
#include "../ncm_app_internal.h"
#include <string.h>
#include <math.h>
#include "ncm_icons.h"
/* ---- 几何 ---- */
#define MINI_H 64 /* 栏带高(含底部进度带/下缘) */
#define MINI_PAD 12 /* 左内边距 */
#define MINI_COV 40 /* 封面缩略边长 */
#define MINI_GAP 12 /* 封面↔文本↔按钮 间距 */
#define MINI_BTN 32 /* 播放/暂停图标绘制边长 */
#define MINI_HITR 22 /* 播放/暂停钮命中半径(略大于图标,好点) */
#define MINI_Z 400 /* 栏带 z:高于页面内容(≤60)/页脚(1),不与 topbar(999,顶部) 相交 */
#define MINI_TITLE 18 /* 歌名字号 */
#define MINI_ART 14 /* 歌手字号 */
/* ---- 状态(UI 线程串行,无需锁)---- */
static int g_mini_id; /* CUSTOM 绘制物 id(0=未注册) */
static ncm_rect_t g_bar_r; /* 栏带矩形(绘制 bbox + 命中区) */
static int g_last_vis, g_last_state, g_last_song;
static long g_last_bucket; /* 进度桶(0..100,1% 粒度) */
static char g_last_id[24]; /* 上次绘制的歌曲 id(切歌判据) */
/* 是否显示 mini 栏:有活动播放会话(state≠STOPPED)且当前非播放页。 */
static int mini_visible(void)
{
if (player_manager_state() == NCM_PC_STOPPED) return 0;
if (meow_ui_activity_active() == ACT_PLAYER) return 0;
return 1;
}
/* 当前播放曲元数据:优先播放快照(窗口无关);快照未就绪退窗口拷贝——与 player.c cur_song_meta 同口径。 */
static int mini_cur_song_meta(ncm_song_t *out)
{
if (player_manager_current_song(out) == 0) return 0;
int sc = player_manager_playing_song();
if (sc >= 0 && appstate_song_copy(sc, out) == 0) return 0;
return -1;
}
/* 圆角图四角裁掉(盖回栏底色):局部复制 player.c round_corners_bg(mini 栏独立绘制,
* 不复用跨文件 static)。 */
static void mini_round_corners(int x, int y, int w, int h, int r, uint32_t bg)
{
if (r > w / 2) r = w / 2;
if (r > h / 2) r = h / 2;
for (int dy = 0; dy < r; dy++) {
int inset = r - (int)sqrt((double)(r * r - (r - dy) * (r - dy)));
if (inset < 0) inset = 0;
if (inset > w / 2) inset = w / 2;
meow_ui_fill_rect(x, y + dy, inset, 1, bg);
meow_ui_fill_rect(x + w - inset, y + dy, inset, 1, bg);
meow_ui_fill_rect(x, y + h - 1 - dy, inset, 1, bg);
meow_ui_fill_rect(x + w - inset, y + h - 1 - dy, inset, 1, bg);
}
}
/* MD3 图标 blit(播放/暂停):ncm_icons.h 32×32 单通道 alpha 最近邻放大到 size×size,居中 (cx,cy)。 */
static void mini_draw_icon(int idx, int cx, int cy, int size, uint32_t color)
{
const unsigned char *src = ncm_icons[idx].data;
if (size <= 0) return;
int x0 = cx - size / 2, y0 = cy - size / 2;
for (int y = 0; y < size; y++) {
int sy = (y * NCM_ICON_SIZE) / size;
if (sy >= NCM_ICON_SIZE) sy = NCM_ICON_SIZE - 1;
for (int x = 0; x < size; x++) {
int sx = (x * NCM_ICON_SIZE) / size;
if (sx >= NCM_ICON_SIZE) sx = NCM_ICON_SIZE - 1;
int a = src[sy * NCM_ICON_SIZE + sx];
if (a > 4) meow_ui_fill_rect_alpha(x0 + x, y0 + y, 1, 1, color, a);
}
}
}
/* 绘制整条 mini 栏。draw_cb 由绘制物注册表在「区域/整帧重画」时调用;仅 mini_visible() 才画。 */
static void mini_draw(void *ctx)
{
(void)ctx;
if (!mini_visible()) return;
const meow_theme *th = meow_theme_get();
const ncm_pal_t *pa = ncm_palette(g_dark);
ncm_rect_t r = g_bar_r;
int W = r.w, H = r.h;
/* 底(surfaceContainer,与列表卡片同色)+ 顶部 1px 分隔线 */
meow_ui_fill_rect(r.x, r.y, W, H, th->surfaceContainer);
meow_ui_fill_rect(r.x, r.y, W, 1, th->outlineVariant);
/* 当前曲元数据(快照优先) */
ncm_song_t sm;
int have = (mini_cur_song_meta(&sm) == 0);
const char *nm = (have && sm.name[0]) ? sm.name : "暂无播放";
const char *ar = (have && sm.art_name[0]) ? sm.art_name : "";
/* 封面缩略(复用 cover 链;cover_get 持锁返回,cover_put 配对) */
int cx = r.x + MINI_PAD, cy = r.y + (H - MINI_COV) / 2;
ncm_fill_round(cx, cy, MINI_COV, MINI_COV, 8, pa->red_ctr);
meow_img *cov = cover_get();
if (cov) {
meow_ui_img_cover(cx, cy, MINI_COV, cov);
mini_round_corners(cx, cy, MINI_COV, MINI_COV, 8, th->surfaceContainer);
} else {
meow_ui_text_center(cx + MINI_COV / 2, cy + (MINI_COV - meow_ui_text_height(th->font_body)) / 2,
"\u266a", th->font_body, pa->red);
}
cover_put();
/* 歌名/歌手(截断到播放钮左) */
int tx = cx + MINI_COV + MINI_GAP;
int maxw = r.x + W - MINI_PAD - tx - (MINI_BTN + MINI_GAP * 2);
if (maxw < 40) maxw = 40;
int ty = r.y + (H - (MINI_TITLE + MINI_ART + 4)) / 2;
meow_ui_text(tx, ty, ncm_text_fit(nm, maxw, MINI_TITLE), MINI_TITLE, th->onSurface);
meow_ui_text(tx, ty + MINI_TITLE + 2, ncm_text_fit(ar, maxw, MINI_ART), MINI_ART, th->onSurfaceVariant);
/* 播放/暂停钮(右侧红 icon;PAUSED/STOPPED → 播放,否则暂停) */
int st = player_manager_state();
int bx = r.x + W - MINI_PAD - MINI_BTN / 2 - MINI_GAP / 2;
int bcy = r.y + H / 2;
mini_draw_icon((st == NCM_PC_PAUSED || st == NCM_PC_STOPPED) ? NCM_I_PLAY : NCM_I_PAUSE,
bx, bcy, MINI_BTN, pa->red);
/* 细进度带(底缘 3px:track surfaceVariant + fill 品牌红) */
int64_t pos = player_manager_pos_ms();
int64_t dur = player_manager_dur_ms();
if (dur <= 0 && have) dur = sm.duration_ms;
int frac = dur > 0 ? (int)(pos * 1000 / dur) : 0;
if (frac < 0) frac = 0; if (frac > 1000) frac = 1000;
meow_ui_fill_rect(r.x, r.y + H - 3, W, 3, th->surfaceVariant);
meow_ui_fill_rect(r.x, r.y + H - 3, (int)((int64_t)W * frac / 1000), 3, pa->red);
}
/* 命中:app.c app_on_tap 顶层先调。非显示态/栏带外 → 0(不消费)。点播放/暂停钮 → toggle;
* 点栏体 → push(ACT_PLAYER)(进播放页)。返回 1=消费(拦截,不再落页面 on_tap)。 */
int mini_bar_hit(int x, int y)
{
if (!mini_visible()) return 0;
if (y < g_bar_r.y || y >= g_bar_r.y + g_bar_r.h ||
x < g_bar_r.x || x >= g_bar_r.x + g_bar_r.w) return 0;
int bx = g_bar_r.x + g_bar_r.w - MINI_PAD - MINI_BTN / 2 - MINI_GAP / 2;
int bcy = g_bar_r.y + g_bar_r.h / 2;
int dx = x - bx, dy = y - bcy;
if (dx * dx + dy * dy <= MINI_HITR * MINI_HITR) {
int st = player_manager_state();
if (st == NCM_PC_PAUSED) player_manager_pause(0); /* 恢复 */
else if (st != NCM_PC_STOPPED) player_manager_pause(1); /* 暂停(含 BUFFER 保守停) */
return 1;
}
meow_view_page_push(ACT_PLAYER);
return 1;
}
/* 每帧(app_on_tick):可见性/歌曲/暂停态/进度 1% 桶 变化 → 失效整条栏带并重绘;
* 切歌 → 复用 cover_request 拉新封面(非播放页也随切歌更新;cover_request 幂等去重,宿主 mock 内跳过网络)。 */
void mini_bar_tick(uint64_t now)
{
(void)now;
int vis = mini_visible();
int st = player_manager_state();
int sc = player_manager_playing_song();
ncm_song_t sm;
int have = (mini_cur_song_meta(&sm) == 0);
const char *id = have ? sm.id : "";
int64_t pos = player_manager_pos_ms(), dur = player_manager_dur_ms();
long bucket = dur > 0 ? (long)(pos * 100 / dur) : 0;
if (bucket < 0) bucket = 0; if (bucket > 100) bucket = 100;
int id_changed = (id[0] && strcmp(id, g_last_id) != 0);
if (vis == g_last_vis && st == g_last_state && sc == g_last_song &&
bucket == g_last_bucket && !id_changed) return;
g_last_vis = vis; g_last_state = st; g_last_song = sc;
g_last_bucket = bucket;
ncm_strlcpy(g_last_id, id, sizeof g_last_id);
/* 切歌 → 请求当前曲封面(专辑图优先,无则歌单封面兜底) */
if (vis && id_changed) {
if (have && sm.al_pic[0]) cover_request(sm.al_pic, sm.id);
else if (have && g_st.cover_url[0]) cover_request(g_st.cover_url, sm.id);
}
if (g_mini_id) meow_ui_invalidate(g_bar_r.x, g_bar_r.y, g_bar_r.w, g_bar_r.h);
}
/* 登记(main 建页后调一次,host/device 通用):注册 CUSTOM 绘制物(跨 Activity 常驻)。 */
void mini_bar_init(void)
{
if (g_mini_id) return;
int W = meow_ui_width(), H = meow_ui_height();
g_bar_r = NR(0, H - MINI_H, W, MINI_H);
g_last_vis = -1; g_last_state = -1; g_last_song = -1; g_last_bucket = -1;
g_last_id[0] = 0;
g_mini_id = meow_draw_custom_register(MINI_Z, g_bar_r.x, g_bar_r.y, g_bar_r.w, g_bar_r.h,
mini_draw, NULL);
}
/* 注销(app 退出前)。 */
void mini_bar_shutdown(void)
{
if (g_mini_id) { meow_draw_unregister(g_mini_id); g_mini_id = 0; }
}
+1 -1
View File
@@ -18,7 +18,7 @@ CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \
core/ncm_crypto.c core/ncm_req.c core/ncm_login.c core/ncm_api.c \
core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c"
APP="app/app.c app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \
app/ui/settings.c app/ui/search.c app/ui/ncm_colors.c app/ui/chrome.c \
app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/ncm_colors.c app/ui/chrome.c \
app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \
app/logic/cover.c app/logic/account.c app/play/player_manager.c app/play/audio_out.c \
app/play/ipc_media.c app/play/decoder.c app/play/stream_src.c app/play/minimp3_impl.c \
+1 -1
View File
@@ -30,7 +30,7 @@ $CC_HOST -O2 -c -I"$SDK_DIR" -I"$ROOT/patch_v2/meow_launcherd" "$SDK_DIR/test_ho
echo "==> host app sources (exclude app.c: has main + g_st; test provides them)"
APP_SRC="app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \
app/ui/settings.c app/ui/search.c app/ui/ncm_colors.c app/ui/chrome.c \
app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/ncm_colors.c app/ui/chrome.c \
app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \
app/logic/cover.c app/logic/account.c app/play/player_manager.c app/play/audio_out.c \
app/play/ipc_media.c app/play/decoder.c app/play/stream_src.c app/play/minimp3_impl.c"
+358
View File
@@ -0,0 +1,358 @@
/*
* meow_lyric_view.c — SDK 通用歌词页渲染控件实现(见 meow_lyric_view.h 的契约说明)。
*
* 上收自 ncm_app/app/ui/player.c:lyr_layout(活动行块中心偏上锚定、上方 ≥LYR_ABOVE_MIN 行已过、
* 下填满、总 ≤LYR_MAX_ROWS)+ plr_draw_lyrics(活动行纯白、上下行逐级灰阶、翻译紧跟随不叠行,
* 块高含翻译行是 agent-380 修好 s_lyr_bh 的关键)+ lyrics_line_tap(命中=布局逐块扫描)。折行复用
* 批1 meow_lyric_wrap(=ncm lyr_wrap_lines 的通用化),宽度测量经注入的 meow_lyric_width_fn。
*
* 控件形态:W_CUSTOM 自绘画布(meow_widget_custom_create),render_cb/hit_cb 由本 TU 实现。
* 布局(lv_compute_layout)计算结果缓存在 lv->vis[0..vis_n),绘制/命中/公开查询共用,三者一致。
*
* 内存纪律:per-instance struct(calloc);布局数组定长 [MEOW_LYRIC_VIEW_MAX_ROWS];折行复用缓冲
* 一块 [MEOW_LYRIC_VIEW_MAX_ROWS][MEOW_LYRIC_SEG_SZ](逐条目复用,同 ncm s_lyr_seg 模式);
* 零临时堆分配(meow_lyric_wrap 静态缓冲)。
*/
#include "meow_lyric_view.h"
#include "meow_ui_internal.h" /* meow_widget_rect:widget 为不透明类型,取几何用访问器 */
#include <stdlib.h>
#include <string.h>
/* 折行宽测量的兜底:字节数近似(仅当使用方未注入 width 时;非像素精确)。
* 保证无渲染引擎下行为确定(maxw 作字节预算,仍按中西文断行)——同 meow_lyric_wrap 的 NULL 语义。 */
static int lv_default_width(const char *u, int size, void *ctx)
{
(void)size; (void)ctx;
return (int)strlen(u);
}
/* 逐通道比例缩放(t=0..255,255=原色)。灰度递减用:对 cur 逐级缩灰(对纯白即复现 ncm 灰阶表)。 */
static uint32_t lv_scale_col(uint32_t c, int t)
{
int r = ((int)((c >> 16) & 255) * t) / 255;
int g = ((int)((c >> 8) & 255) * t) / 255;
int b = ((int)( c & 255) * t) / 255;
return ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b;
}
/* 灰阶比例(距活动行 0..5):0xFFFFFF→{FFFFFF,BBBBBB,929292,808080,6A6A6A,555555}(ncm lyr_gray_tbl)。
* 对非纯白 cur 按同比例缩灰,仍单调变灰。 */
static const int lv_gray_fac[6] = { 255, 187, 146, 128, 106, 85 };
/* 两色线性混合(t=0..255,0=纯 a、255=纯 b)。翻译行融入背景用(无 GPU,逐通道整数混)。 */
static uint32_t lv_mix(uint32_t a, uint32_t b, int t)
{
if (t < 0) t = 0;
if (t > 255) t = 255;
int ar = (a >> 16) & 255, ag = (a >> 8) & 255, ab = a & 255;
int br = (b >> 16) & 255, bg = (b >> 8) & 255, bb = b & 255;
int r = ar + ((br - ar) * t + 128) / 255;
int g = ag + ((bg - ag) * t + 128) / 255;
int bl = ab + ((bb - ab) * t + 128) / 255;
return ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)bl;
}
struct meow_lyric_view {
meow_widget *w;
/* 宽度测量(复用批1 meow_lyric_wrap 的 width_fn 约定;NULL→字节近似兜底) */
meow_lyric_width_fn width;
void *width_ctx;
int64_t pos_ms; /* 当前播放位置(UI 线程写) */
meow_lyric_view_colors colors; /* 用户覆盖(has_colors=1 生效;否则主题默认) */
int has_colors;
meow_lyric_view_layout l; /* 布局参数(内建默认/用户覆盖) */
char empty[64]; /* 无歌词占位文案 */
meow_lyric_seek_fn on_seek;
void *seek_ctx;
/* 布局暂存(每次 lv_compute_layout 重建;UI 线程串行,per-instance 复用) */
meow_lyric_view_vis vis[MEOW_LYRIC_VIEW_MAX_ROWS];
int vis_n;
char seg[MEOW_LYRIC_VIEW_MAX_ROWS][MEOW_LYRIC_SEG_SZ]; /* 折行复用缓冲(逐条目覆盖) */
};
/* 内建默认布局(对齐 ncm LYR_PAD/SUB_LH/CUR_SZ/OTH_SZ/TL_SZ/MAX_ROWS/ABOVE_MIN/ABOVE_OFF)。 */
static const meow_lyric_view_layout k_def_layout = {
24, 44, 28, 24, 20, MEOW_LYRIC_VIEW_MAX_ROWS, 2, 50
};
static const char k_def_empty[] = "歌词加载中…";
/* 解析生效色:!has_colors 或字段 0/scrim_alpha<0 → 主题默认;否则用覆盖。out 必填。 */
static void lv_resolve_colors(const meow_lyric_view *lv, const meow_theme *th,
meow_lyric_view_colors *out)
{
/* 内建默认(theme 驱动) */
uint32_t scrim = th->scrim;
if (scrim == 0) scrim = th->surfaceDim; /* scrim 缺省为黑:用 surfaceDim 兜底(非纯黑) */
uint32_t cur = th->onSurface; /* dark=0xFFFFFF 纯白(活动行高亮) */
out->scrim = scrim;
out->cur = cur;
out->scrim_alpha = 176;
out->gray[0] = cur;
for (int i = 1; i < 6; i++) out->gray[i] = lv_scale_col(cur, lv_gray_fac[i]);
if (!lv->has_colors) return;
/* 用户覆盖(0 字段=沿用上面内建默认;scrim_alpha<0=沿用默认 176) */
if (lv->colors.scrim != 0) out->scrim = lv->colors.scrim;
if (lv->colors.cur != 0) { out->cur = lv->colors.cur; out->gray[0] = lv->colors.cur; }
if (lv->colors.scrim_alpha >= 0) out->scrim_alpha = lv->colors.scrim_alpha;
for (int i = 0; i < 6; i++)
if (lv->colors.gray[i] != 0) out->gray[i] = lv->colors.gray[i];
}
/* 距活动行 dist 的生效行色(dist 越界 clamp 0..5)。 */
static uint32_t lv_line_color(const meow_lyric_view *lv, const meow_theme *th, int dist)
{
meow_lyric_view_colors c;
lv_resolve_colors(lv, th, &c);
if (dist < 0) dist = 0;
if (dist > 5) dist = 5;
return c.gray[dist];
}
/* 折行封套(width 缺省兜底;越界 max_rows 内 clamp 到缓冲容量)。返回段数(≥1)。 */
static int lv_wrap(const meow_lyric_view *lv, const char *txt, int size, int maxw,
char seg[][MEOW_LYRIC_SEG_SZ], int max_rows)
{
meow_lyric_width_fn w = lv->width ? lv->width : lv_default_width;
if (max_rows > MEOW_LYRIC_VIEW_MAX_ROWS) max_rows = MEOW_LYRIC_VIEW_MAX_ROWS;
int rc = meow_lyric_wrap(txt, size, maxw, seg, max_rows, w, lv->width_ctx);
if (rc > max_rows) rc = max_rows;
return rc;
}
/* 核心布局:按 pos_ms 计算可见条目(vis[0..n)。算法与 ncm lyr_layout 逐行为一致)——
* 活动行块中心锚定在视图中线 - above_off(偏上);上方保底 ≥above_min 行已过;下填满;总 ≤max_rows;
* 块高 bh = 原文折行数 rw + 当前行翻译占 1(其余条目无翻译,bh=rw;翻译不叠行,点行命中一致)。 */
static int lv_compute_layout(meow_lyric_view *lv, int64_t pos_ms, int maxw)
{
int n = meow_lyric_count();
int cur = (n > 0) ? meow_lyric_line_at(pos_ms) : -1;
memset(lv->vis, 0, sizeof(lv->vis));
lv->vis_n = 0;
if (cur < 0 || cur >= n) return 0;
int MAX = lv->l.max_rows;
if (MAX > MEOW_LYRIC_VIEW_MAX_ROWS) MAX = MEOW_LYRIC_VIEW_MAX_ROWS;
if (MAX < 1) MAX = 1;
int rc = lv_wrap(lv, meow_lyric_line_text(cur), lv->l.cur_sz, maxw, lv->seg, MAX);
const char *tl = meow_lyric_line_tl(cur);
int has_tl = (tl && tl[0]) ? 1 : 0;
int rc_tot = rc + has_tl; /* 活动行块总子行数(原文折行 + 翻译行) */
int cap = MAX - rc_tot; /* 活动行之外可用的视觉行数 */
if (cap < 0) cap = 0;
int above_want = (cap * 2) / 5; /* 偏上锚定:约 40% 行给上方 */
if (above_want > cur) above_want = cur; /* 上方行数上限 = 已过行数 */
if (above_want < lv->l.above_min) above_want = (cur >= lv->l.above_min) ? lv->l.above_min : cur;
if (above_want > cap) above_want = cap; /* 总行数 ≤ MAX */
/* 1) 上方(cur-1 向上)前插到头部,保持自顶向下 */
int a_cnt = 0, usedA = 0;
for (int i = cur - 1; i >= 0 && usedA < above_want; i--) {
int rr = lv_wrap(lv, meow_lyric_line_text(i), lv->l.oth_sz, maxw, lv->seg, MAX);
if (usedA + rr > above_want) break;
for (int k = a_cnt; k > 0; k--) lv->vis[k] = lv->vis[k - 1];
lv->vis[0].ent = i; lv->vis[0].rw = rr; lv->vis[0].bh = rr; lv->vis[0].sz = lv->l.oth_sz;
a_cnt++; usedA += rr;
}
/* 2) 当前行 */
lv->vis[a_cnt].ent = cur; lv->vis[a_cnt].rw = rc; lv->vis[a_cnt].bh = rc_tot; lv->vis[a_cnt].sz = lv->l.cur_sz;
/* 3) 下方(cur+1 向下) */
int below_want = cap - usedA;
if (below_want > (n - 1 - cur)) below_want = n - 1 - cur;
int b_cnt = 0, usedB = 0;
for (int i = cur + 1; i < n && usedB < below_want; i++) {
int rr = lv_wrap(lv, meow_lyric_line_text(i), lv->l.oth_sz, maxw, lv->seg, MAX);
if (usedB + rr > below_want) break;
int k = a_cnt + 1 + b_cnt;
lv->vis[k].ent = i; lv->vis[k].rw = rr; lv->vis[k].bh = rr; lv->vis[k].sz = lv->l.oth_sz;
b_cnt++; usedB += rr;
}
int cnt = a_cnt + 1 + b_cnt;
if (cnt == 0) return 0;
/* 4) y 定位:活动行原文块中心 = 视图中线 - above_off(偏上);上方向上、下方向下(块间无缝隙) */
int rect[4]; meow_widget_rect(lv->w, rect);
int anchor = rect[1] + rect[3] / 2 - lv->l.above_off; /* rect[1]=vy, rect[3]=vh */
lv->vis[a_cnt].y = anchor - rc * lv->l.sub_lh / 2;
for (int k = a_cnt + 1; k < cnt; k++)
lv->vis[k].y = lv->vis[k - 1].y + lv->vis[k - 1].bh * lv->l.sub_lh;
for (int k = a_cnt - 1; k >= 0; k--)
lv->vis[k].y = lv->vis[k + 1].y - lv->vis[k].bh * lv->l.sub_lh;
lv->vis_n = cnt;
return cnt;
}
/* 自绘回调:scrim 底 + 滚动歌词(活动行纯白偏上锚定 + 上下行逐级灰阶)+ 翻译行(不叠行)+ 空态占位。 */
static void lv_render(meow_widget *w, const meow_theme *th, void *ctx)
{
meow_lyric_view *lv = (meow_lyric_view *)ctx;
int rect[4]; meow_widget_rect(w, rect);
int x = rect[0], vy = rect[1], W = rect[2], H = rect[3];
meow_lyric_view_colors col;
lv_resolve_colors(lv, th, &col);
/* 背景 scrim(半透明叠在封面之上;scrim_alpha==0=不画,App 已自绘叠加层)。 */
if (col.scrim_alpha > 0) meow_ui_fill_rect_alpha(x, vy, W, H, col.scrim, col.scrim_alpha);
int n = meow_lyric_count();
int cur = (n > 0) ? meow_lyric_line_at(lv->pos_ms) : -1;
int maxw = W - 2 * lv->l.pad; if (maxw < 1) maxw = 1;
int cnt = lv_compute_layout(lv, lv->pos_ms, maxw);
uint32_t bgcol = col.scrim; /* 翻译行融入背景(渐隐) */
for (int k = 0; k < cnt; k++) {
int i = lv->vis[k].ent, rows = lv->vis[k].rw, sz = lv->vis[k].sz, vsy = lv->vis[k].y;
int is_cur = (i == cur);
int dist = is_cur ? 0 : ((i - cur) < 0 ? (cur - i) : (i - cur));
uint32_t c = lv_line_color(lv, th, dist);
int rr = lv_wrap(lv, meow_lyric_line_text(i), sz, maxw, lv->seg, rows);
if (rr > rows) rr = rows;
for (int r = 0; r < rr; r++)
meow_ui_text(x + lv->l.pad, vsy + r * lv->l.sub_lh, lv->seg[r], sz, c);
if (is_cur) {
const char *tl = meow_lyric_line_tl(i);
if (tl && tl[0])
meow_ui_text(x + lv->l.pad, vsy + rows * lv->l.sub_lh + 2, tl,
lv->l.tl_sz, lv_mix(0xFFFFFF, bgcol, 120));
}
}
if (n == 0)
meow_ui_text(x + lv->l.pad, vy + H / 2 - 10, lv->empty, 20, col.cur);
}
/* 命中回调:点行 seek(用与绘制一致的布局逐块扫描;块外就近取整)。
* 命中某行 → 存 last 行 + 回调 on_seek(ms)(若有),返回 1=消费;否则 0。 */
static int lv_hit_cb(meow_widget *w, int gx, int gy, void *ctx)
{
meow_lyric_view *lv = (meow_lyric_view *)ctx;
int rect[4]; meow_widget_rect(w, rect);
int rx = rect[0], ry = rect[1], rw = rect[2], rh = rect[3];
/* 视图矩形内含判定(含边缘;点必须在控件内)。 */
if (gx < rx || gx >= rx + rw || gy < ry || gy >= ry + rh) return 0;
int n = meow_lyric_count();
if (n <= 0) return 0;
int maxw = rw - 2 * lv->l.pad; if (maxw < 1) maxw = 1;
int cnt = lv_compute_layout(lv, lv->pos_ms, maxw);
if (cnt <= 0) return 0;
int line = -1;
for (int k = 0; k < cnt; k++) {
int yb = lv->vis[k].y;
int yh = lv->vis[k].bh * lv->l.sub_lh; /* 块高含当前行翻译,点翻译行归本行 */
if (gy >= yb && gy < yb + yh) { line = lv->vis[k].ent; break; }
}
if (line < 0) /* 落在可见块之外 → 就近取整(顶部/底部可见行) */
line = (gy < lv->vis[0].y) ? lv->vis[0].ent : lv->vis[cnt - 1].ent;
if (lv->on_seek) {
int64_t t = meow_lyric_line_time(line);
if (t >= 0) lv->on_seek(t, lv->seek_ctx);
}
return 1;
}
/* ---- 生命周期 ---- */
meow_lyric_view *meow_lyric_view_create(int x, int y, int w, int h,
meow_lyric_width_fn width, void *width_ctx)
{
meow_lyric_view *lv = (meow_lyric_view *)calloc(1, sizeof *lv);
if (!lv) return NULL;
lv->w = meow_widget_custom_create(x, y, w, h, lv_render, lv_hit_cb, lv);
if (!lv->w) { free(lv); return NULL; }
lv->width = width;
lv->width_ctx = width_ctx;
lv->l = k_def_layout;
memcpy(lv->empty, k_def_empty, sizeof k_def_empty);
return lv;
}
void meow_lyric_view_free(meow_lyric_view *lv)
{
if (!lv) return;
if (lv->w) meow_widget_free(lv->w);
free(lv);
}
meow_widget *meow_lyric_view_widget(const meow_lyric_view *lv)
{
return lv ? lv->w : NULL;
}
void meow_lyric_view_set_pos(meow_lyric_view *lv, int64_t pos_ms)
{
if (lv) lv->pos_ms = pos_ms;
}
void meow_lyric_view_set_colors(meow_lyric_view *lv, const meow_lyric_view_colors *c)
{
if (!lv) return;
if (!c) { lv->has_colors = 0; return; }
lv->colors = *c;
lv->has_colors = 1;
}
void meow_lyric_view_set_layout(meow_lyric_view *lv, const meow_lyric_view_layout *ls)
{
if (!lv) return;
lv->l = ls ? *ls : k_def_layout;
}
void meow_lyric_view_set_empty(meow_lyric_view *lv, const char *txt)
{
if (!lv) return;
if (!txt || !txt[0]) { lv->empty[0] = 0; return; }
strncpy(lv->empty, txt, sizeof lv->empty - 1);
lv->empty[sizeof lv->empty - 1] = 0;
}
void meow_lyric_view_set_on_seek(meow_lyric_view *lv, meow_lyric_seek_fn cb, void *ctx)
{
if (!lv) return;
lv->on_seek = cb;
lv->seek_ctx = ctx;
}
/* ---- 查询 ---- */
uint32_t meow_lyric_view_cur_color(const meow_lyric_view *lv)
{
if (!lv) return 0;
return lv_line_color(lv, meow_theme_get(), 0);
}
uint32_t meow_lyric_view_line_color(const meow_lyric_view *lv, int dist)
{
if (!lv) return 0;
return lv_line_color(lv, meow_theme_get(), dist);
}
int meow_lyric_view_hit_line(meow_lyric_view *lv, int x, int y)
{
if (!lv || !lv->w) return -1;
int rect[4]; meow_widget_rect(lv->w, rect);
int rx = rect[0], ry = rect[1], rw = rect[2], rh = rect[3];
if (x < rx || x >= rx + rw || y < ry || y >= ry + rh) return -1;
int n = meow_lyric_count();
if (n <= 0) return -1;
int maxw = rw - 2 * lv->l.pad; if (maxw < 1) maxw = 1;
int cnt = lv_compute_layout(lv, lv->pos_ms, maxw);
if (cnt <= 0) return -1;
for (int k = 0; k < cnt; k++) {
int yb = lv->vis[k].y, yh = lv->vis[k].bh * lv->l.sub_lh;
if (y >= yb && y < yb + yh) return lv->vis[k].ent;
}
return (y < lv->vis[0].y) ? lv->vis[0].ent : lv->vis[cnt - 1].ent;
}
int meow_lyric_view_visible(meow_lyric_view *lv, int64_t pos_ms,
meow_lyric_view_vis *out, int cap)
{
if (!lv || !lv->w) return 0;
int rect[4]; meow_widget_rect(lv->w, rect);
int maxw = rect[2] - 2 * lv->l.pad; if (maxw < 1) maxw = 1;
int cnt = lv_compute_layout(lv, pos_ms, maxw);
if (out && cnt > 0) {
int n = (cnt < cap) ? cnt : cap;
for (int i = 0; i < n; i++) out[i] = lv->vis[i];
}
return cnt;
}
+110
View File
@@ -0,0 +1,110 @@
/*
* meow_lyric_view.h — MeowOS SDK 通用歌词页渲染控件(可复用,供 launcher Now Playing 与 ncm 共用)。
*
* 定位:把 ncm_app/app/ui/player.c 已成熟(agent-380 修好 s_lyr_bh)的歌词页布局上收成 SDK 控件。
* 能力对齐 ncm 歌词页:活动行纯白(居中偏上锚定)、上下行按行距逐级灰阶、翻译行紧跟原文行不叠行
* (块高含翻译行)、超宽折行(中西文兼容)、上方保底 ≥above_min 行已过、总视觉行数 ≤max_rows、
* 点行 seek 命中与绘制一致(同一布局函数)。
*
* 数据来源:**绑定批1 meow_lyric**(解析/查询 API:meow_lyric_count/line_at/line_text/line_tl/line_time,
* 全局单例,线程安全)+ 播放位置(meow_lyric_view_set_pos)。宽度测量用回调注入
* (`meow_lyric_width_fn`,包 meow_ui_text_width 的包装;宿主测试传自己的测量函数)。
*
* 控件形态:内部持一个 `meow_widget`(W_CUSTOM 自定义画布),经 meow_lyric_view_widget() 取出供
* meow_widget_draw/hit/注册;render_cb/hit_cb 都由本控件自绘/自判。App 也可只用纯查询接口
* (meow_lyric_view_visible/hit_line)在自绘流程里复用布局逻辑。
*
* 主题:用 SDK 全局 meow_theme 为默认(明暗背景 scrim、活动行 onSurface=dark 纯白、灰阶递减),
* 为批5「封面取色动态主题」预留 meow_lyric_view_set_colors 覆盖入口(字段 0=仍用主题默认)。
*
* 依赖:纯 SDK(meow_ui.h + meow_lyric.h),不引任何 ncm 私有符号;可链 meow_ui.a 使用,
* 亦可宿主 gcc 单测(同 test_now_playing_widgets 链法)。本头为**独立公共头**(仿 meow_ui_view.h
* 模式,不随 meow_ui.h 伞头发布),App 显式 `#include "meow_lyric_view.h"`。
*
* 命名:API 一律 `meow_lyric_view_*`(对齐 SDK 规范);控件内布局/绘制/命中共享同一
* meow_lyric_view_visible 计算,保证「点行 seek 命中与绘制一致」。
*/
#ifndef MEOW_LYRIC_VIEW_H
#define MEOW_LYRIC_VIEW_H
#include "meow_ui.h" /* meow_widget / meow_theme / meow_ui_text 等 */
#include "meow_lyric.h" /* meow_lyric_width_fn + 行查询 API(批1) */
#ifdef __cplusplus
extern "C" {
#endif
typedef struct meow_lyric_view meow_lyric_view;
/* 可见层数硬上限(对齐 ncm LYR_MAX_ROWS;数组按此定容,运行时 layout.max_rows 不得越此)。 */
#define MEOW_LYRIC_VIEW_MAX_ROWS 9
/* ---- 可见条目(布局/命中/绘制共用,宿主可测)----
* ent=歌词行下标;rw=原文折行段数;bh=块高(子行数)=rw + 当前行翻译占 1(其余条目 bh=rw);
* sz=该条目字号;y=块顶 y(绝对屏幕坐标)。 */
typedef struct meow_lyric_view_vis {
int ent;
int rw;
int bh;
int sz;
int y;
} meow_lyric_view_vis;
/* ---- 主题色覆盖(批5 封面取色动态主题预留)----
* 全部字段:0(scrim_alpha<0)= 用 meow_theme 内建默认;显式值 = 覆盖。
* 默认映射:scrim=th->scrim(黑叠加在封面之上,alpha 176);cur=th->onSurface(dark=0xFFFFFF 纯白);
* gray[0]=cur,gray[1..5]=对 cur 逐级缩灰(对纯白即 ncm 的 {BBBBBB,929292,808080,6A6A6A,555555})。
* scrim_alpha==0 = 不画 scrim(App 已自绘封面/叠加层时用)。 */
typedef struct meow_lyric_view_colors {
uint32_t scrim; /* 背景 scrim 色(半透明叠加在封面之上) */
uint32_t cur; /* 活动行色(默认 th->onSurface;dark 下=纯白) */
uint32_t gray[6]; /* 距活动行 0..5 灰阶;gray[0]=cur。字段 0=自动递减 */
int scrim_alpha; /* scrim 透明度 0-255;<0=默认(176);0=不画 scrim */
} meow_lyric_view_colors;
/* ---- 布局参数(对齐 ncm;NULL=内建默认)---- */
typedef struct meow_lyric_view_layout {
int pad; /* 左边距(默认 24) */
int sub_lh; /* 子行高(默认 44) */
int cur_sz; /* 活动行字号(默认 28) */
int oth_sz; /* 上下行字号(默认 24) */
int tl_sz; /* 当前行翻译字号(默认 20) */
int max_rows; /* 可见视觉行数上限(默认 9;≤MEOW_LYRIC_VIEW_MAX_ROWS) */
int above_min; /* 活动行上方保底已过行数(默认 2) */
int above_off; /* 活动行块中心相对视图中线偏上 px(默认 50,需求3 偏上锚定) */
} meow_lyric_view_layout;
/* 点行 seek 回调:meow_lyric_view_set_on_seek 设置;命中某行后回调 line_time(ms)。 */
typedef void (*meow_lyric_seek_fn)(int64_t ms, void *ctx);
/* ---- 生命周期 ---- */
meow_lyric_view *meow_lyric_view_create(int x, int y, int w, int h,
meow_lyric_width_fn width, void *width_ctx);
void meow_lyric_view_free(meow_lyric_view *lv);
/* 底层 W_CUSTOM meow_widget:App 用它做 draw/hit/注册(launcher 控件模型)。 */
meow_widget *meow_lyric_view_widget(const meow_lyric_view *lv);
/* ---- 数据绑定 ---- */
void meow_lyric_view_set_pos(meow_lyric_view *lv, int64_t pos_ms); /* 播放器位置(ms) */
/* ---- 主题/布局/空态 ---- */
void meow_lyric_view_set_colors(meow_lyric_view *lv, const meow_lyric_view_colors *c); /* NULL=回主题默认 */
void meow_lyric_view_set_layout(meow_lyric_view *lv, const meow_lyric_view_layout *ls); /* NULL=回内建默认 */
void meow_lyric_view_set_empty(meow_lyric_view *lv, const char *txt); /* 无歌词占位文案;NULL=默认 */
/* 点行 seek(widget hit 命中时回调;App 也可只用 meow_lyric_view_hit_line 自行 seek)。 */
void meow_lyric_view_set_on_seek(meow_lyric_view *lv, meow_lyric_seek_fn cb, void *ctx);
/* ---- 查询 ——
* cur_color / line_color:取该视图生效(主题默认或覆盖)的活动行色 / 距活动行 dist(0..5) 行色。
* visible:按 pos_ms 计算可见条目布局(与绘制/命中同一算法)写入 out[≤cap],返回条目数(≤0=空/无活动行)。
* hit_line:点 (x,y) 命中某歌词行下标(与绘制一致的布局;块外就近取整);未命中返回 -1。 */
uint32_t meow_lyric_view_cur_color(const meow_lyric_view *lv);
uint32_t meow_lyric_view_line_color(const meow_lyric_view *lv, int dist);
int meow_lyric_view_hit_line(meow_lyric_view *lv, int x, int y);
int meow_lyric_view_visible(meow_lyric_view *lv, int64_t pos_ms,
meow_lyric_view_vis *out, int cap);
#ifdef __cplusplus
}
#endif
#endif /* MEOW_LYRIC_VIEW_H */
BIN
View File
Binary file not shown.
+319
View File
@@ -0,0 +1,319 @@
/*
* test_meow_lyric_view.c — meow_lyric_view(SDK 通用歌词页控件)宿主单测(2026-09-03,批2)。
*
* 编译(宿主 gcc,链真实 gfx/sys/theme/vol/log/widget/input/activity/dialog/ime + 打桩 font/global;
* 另链批1 meow_lyric.c 提供歌词数据;meow_lyric_view.c 为被测控件):
* gcc -Wall -Wextra -o test_meow_lyric_view test_meow_lyric_view.c test_host_stub.c \
* meow_lyric.c meow_lyric_view.c \
* meow_ui_gfx.c meow_ui_sys.c meow_ui_theme.c meow_ui_vol.c meow_ui_log.c \
* meow_ui_widget.c meow_ui_input.c meow_ui_activity.c meow_ui_dialog.c meow_ui_ime.c meow_ui_str.c \
* meow_pinyin.c meow_ui_draw.c meow_ui_view.c meow_ui_box.c meow_ui_anim.c meow_ui_scroll.c \
* meow_ui_img.c meow_ui_media.c meow_notify_ipc.c stb_image.c \
* -I. -I../meow_launcherd -DMEOW_UI_HOST_TEST -pthread -lm
* 运行:./test_meow_lyric_view
*
* 覆盖矩阵(对齐任务批2:活动行纯白/灰阶、翻译行占位不叠行、偏上锚定、折行中西文、点行 seek 命中、
* 主题换色、scrim 像素渲染):
* L1 活动行纯白 + 上下行逐级灰阶单调递减(dark 主题:onSurface=0xFFFFFF 纯白)
* L2 翻译行占位不叠行:3 行原文+翻译 → 活动行块高 bh=rw+1,下一块始于翻译行之后(块间无重叠)
* L3 偏上锚定:活动行原文块中心 = 视图中线 - above_off(严格偏上)
* L4 折行中西文:长行折成多段(rw>1),且块间按子行高排列
* L5 点行 seek 命中:命中=布局一致(点块内/块外就近取整;点翻译行归本行不误触下一行)
* L6 主题换色:set_colors 覆盖活动行/灰阶/背景;NULL 恢复主题默认
* L7 scrim 像素渲染:draw 后背底像素≈scrim 色(fill_rect_alpha 真实落地)
*/
#include "meow_ui.h"
#include "meow_ui_internal.h" /* meow_ui_back_buf/pitch/bpp:真实像素断言 */
#include "meow_lyric_view.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
static int g_fail = 0;
#define CHECK(cond, name) do { \
if (cond) printf(" PASS %s\n", name); \
else { printf(" FAIL %s (%s:%d)\n", name, __FILE__, __LINE__); g_fail++; } } while (0)
/* on_seek 回调收(验证 widget hit 路径回调接线)。 */
static int64_t g_seek_ms = -1;
static void seek_cb(int64_t ms, void *ctx) { (void)ctx; g_seek_ms = ms; }
/* ---- back_buf 像素读取(断言真实渲染) ---- */
static void rd_px(int x, int y, int *r, int *g, int *b)
{
unsigned char *bb = meow_ui_back_buf();
int line = meow_ui_back_buf_pitch(), bpp = meow_ui_back_buf_bpp();
if (!bb || x < 0 || y < 0) { *r = *g = *b = 0; return; }
if (bpp == 16) {
uint16_t px = *(uint16_t *)(bb + (size_t)y * line + (size_t)x * 2);
*r = (((px >> 11) & 0x1F) << 3) | ((px >> 11) & 0x1F) >> 2;
*g = (((px >> 5) & 0x3F) << 2) | ((px >> 5) & 0x3F) >> 4;
*b = ((px & 0x1F) << 3) | ((px & 0x1F) >> 2);
} else {
uint32_t px = *(uint32_t *)(bb + (size_t)y * line + (size_t)x * 4);
*r = (px >> 16) & 0xFF; *g = (px >> 8) & 0xFF; *b = px & 0xFF;
}
}
static int near2(int a, int b) { int d = a - b; if (d < 0) d = -d; return d <= 14; }
static int px_near(int x, int y, int r, int g, int b)
{
int rr, gg, bb; rd_px(x, y, &rr, &gg, &bb);
return near2(rr, r) && near2(gg, g) && near2(bb, b);
}
/* 折行宽测量(宿主):ASCII=1 单位,CJK 多字节=2 单位(中西文断行用)。 */
static int lw(const char *u, int size, void *ctx)
{
(void)size; (void)ctx;
int w = 0;
for (const char *p = u; *p; ) {
unsigned char c = (unsigned char)*p;
int ch = (c < 0x80) ? 1 : (c < 0xE0) ? 2 : (c < 0xF0) ? 3 : 4;
w += (c < 0x80) ? 1 : 2;
p += ch;
}
return w;
}
/* 亮度(灰度单调性判断用)。 */
static int lum(uint32_t c)
{
return ((c >> 16) & 255) * 3 + ((c >> 8) & 255) * 6 + (c & 255);
}
/*
* 测试歌词:7 行原创 + 两行翻译(与原文同戳合并)。下标:
* 0 First(1000) / 1 Second(2000,tl=第二句翻译) / 2 Third(3000) / 3 Fourth(4000)
* 4 Fifth(5000,tl=第五句翻译长文本折叠测试啊啊啊) / 5 Sixth(6000) / 6 Seventh(7000)
*/
static void load_lyric(void)
{
const char *lrc =
"[00:01.00]First\n"
"[00:02.00]Second\n"
"[00:03.00]Third\n"
"[00:04.00]Fourth\n"
"[00:05.00]Fifth\n"
"[00:06.00]Sixth\n"
"[00:07.00]Seventh\n";
const char *tlrc =
"[00:02.00]\u7b2c\u4e8c\u53e5\u7ffb\u8bd1\n"
"[00:05.00]\u7b2c\u4e94\u53e5\u7ffb\u8bd1\u957f\u6587\u672c\u6298\u53e0\u6d4b\u8bd5\u554a\u554a\u554a\n";
int n = meow_lyric_parse(lrc, tlrc);
assert(n == 7);
/* 翻译合并校验(批1 语义,作前置保证) */
assert(strcmp(meow_lyric_line_tl(1), "\u7b2c\u4e8c\u53e5\u7ffb\u8bd1") == 0);
assert(meow_lyric_line_tl(2)[0] == 0);
}
static void l1_white_gray(void)
{
printf("L1 活动行纯白 + 灰阶单调递减\n");
meow_theme_set_mode(MEOW_THEME_DARK);
meow_lyric_view *lv = meow_lyric_view_create(0, 0, 480, 720, lw, NULL);
assert(lv);
const meow_theme *th = meow_theme_get();
/* 活动行 = 主题 onSurface(dark=0xFFFFFF 纯白) */
uint32_t cur = meow_lyric_view_cur_color(lv);
CHECK(cur == th->onSurface, "cur == th->onSurface (dark=0xFFFFFF 纯白)");
CHECK((cur & 0xFFFFFF) == 0xFFFFFF, "cur 为纯白 0xFFFFFF");
CHECK(meow_lyric_view_line_color(lv, 0) == cur, "line_color(0)==cur");
/* 灰阶单调递减(距活动行越远越灰) */
int mono = 1;
for (int d = 1; d <= 5; d++)
if (lum(meow_lyric_view_line_color(lv, d)) >= lum(meow_lyric_view_line_color(lv, d - 1))) mono = 0;
CHECK(mono, "gray dist 1..5 亮度单调递减");
/* 最远灰阶(对齐 ncm 0x555555) */
CHECK(meow_lyric_view_line_color(lv, 5) == 0x555555, "gray(5)==0x555555 (ncm lyr_gray_tbl)");
meow_lyric_view_free(lv);
}
static void l2_translation_block(void)
{
printf("L2 翻译行占位不叠行(3 行原文 + 翻译)\n");
meow_theme_set_mode(MEOW_THEME_DARK);
meow_lyric_view *lv = meow_lyric_view_create(0, 0, 480, 720, lw, NULL);
assert(lv);
meow_lyric_view_set_pos(lv, 2000); /* cur = idx1 Second(有翻译) */
meow_lyric_view_vis vis[MEOW_LYRIC_VIEW_MAX_ROWS];
int cnt = meow_lyric_view_visible(lv, 2000, vis, MEOW_LYRIC_VIEW_MAX_ROWS);
CHECK(cnt > 0, "可见条目数 > 0");
/* 找出当前行(idx1)所在块与下一块 */
int ci = -1, next = -1;
for (int k = 0; k < cnt; k++) {
if (vis[k].ent == 1) ci = k;
else if (ci >= 0 && next < 0) next = k; /* 当前块之后的第一块 */
}
CHECK(ci >= 0, "当前行(Second)在可见块中");
CHECK(vis[ci].bh == vis[ci].rw + 1, "当前行块高 bh=rw+1(原文折行 + 翻译行占 1 子行)");
if (next >= 0) {
/* 下一块顶 = 当前块顶 + 当前块高×子行高(块间无重叠、翻译行不叠进下一行) */
CHECK(vis[next].y == vis[ci].y + vis[ci].bh * 44, "下一块始于翻译行之后(无叠行)");
} else {
/* 当前行是最后可视块:其块高应把翻译行算进去(翻译行占位,否则行会被截断) */
CHECK(vis[ci].y + vis[ci].bh * 44 <= 720, "当前块含翻译行(占用垂直空间)");
}
meow_lyric_view_free(lv);
}
static void l3_above_anchor(void)
{
printf("L3 偏上锚定(活动行块中心在视图中线上方)\n");
meow_theme_set_mode(MEOW_THEME_DARK);
meow_lyric_view *lv = meow_lyric_view_create(0, 100, 480, 500, lw, NULL); /* 视图中线=350 */
assert(lv);
meow_lyric_view_set_pos(lv, 5000); /* cur = idx4 Fifth(5 行之后,上方充足) */
meow_lyric_view_vis vis[MEOW_LYRIC_VIEW_MAX_ROWS];
int cnt = meow_lyric_view_visible(lv, 5000, vis, MEOW_LYRIC_VIEW_MAX_ROWS);
CHECK(cnt > 0, "可见条目数 > 0");
int ci = -1;
for (int k = 0; k < cnt; k++) if (vis[k].ent == 4) { ci = k; break; }
CHECK(ci >= 0, "当前行(Fifth)在可见块中");
/* 活动行原文块中心 = 块顶 + rw×子行高/2 = 视图中线 - above_off(严格偏上 50px) */
int center = vis[ci].y + vis[ci].rw * 44 / 2;
CHECK(center == 350 - 50, "活动行块中心=350-50=300(偏上锚定 above_off=50)");
CHECK(center < 350, "活动行块中心在视图中线(350)偏上");
meow_lyric_view_free(lv);
}
static void l4_wrap_cjk_west(void)
{
printf("L4 折行中西文(长行折多段,块间按子行高排列)\n");
meow_theme_set_mode(MEOW_THEME_DARK);
/* 窄视图强制折行:w=120 → maxw=120-48=72 单位 */
meow_lyric_view *lv = meow_lyric_view_create(0, 0, 120, 720, lw, NULL);
assert(lv);
/* 用长英文+中文混排行,使当前行折成 >1 段 */
meow_lyric_parse(
"[00:01.00]The quick brown fox jumps over the lazy dog and runs far away into the woods to find shelter from the cold rain today\n"
"[00:05.00]\u4e2d\u6587\u4e2d\u6587\u4e2d\u6587\u4e2d\u6587\u4e2d\u6587\u4e2d\u6587\u4e2d\u6587\u4e2d\u6587\u4e2d\u6587\u8bba\u6587\u6d4b\u8bd5\u554a\u554a\u554a\u554a\n",
NULL);
meow_lyric_view_set_pos(lv, 1000);
meow_lyric_view_vis vis[MEOW_LYRIC_VIEW_MAX_ROWS];
int cnt = meow_lyric_view_visible(lv, 1000, vis, MEOW_LYRIC_VIEW_MAX_ROWS);
CHECK(cnt >= 2, "该行折成多段(可见条目含上下行)");
/* 当前行(idx0,长文本)应从 1 段折成多段 */
int ci = -1;
for (int k = 0; k < cnt; k++) if (vis[k].ent == 0) { ci = k; break; }
CHECK(ci >= 0, "当前行在可见块中");
CHECK(vis[ci].rw > 1, "长行折成 rw>1 段(中西文按 词/字符 断行)");
/* 块内子行间距为 sub_lh:子行间不重叠(块高=rw 子行) */
CHECK(vis[ci].bh == vis[ci].rw, "无翻译当前行 bh=rw(子行数=折行段数)");
/* 恢复 7 行歌词(供后续用例) */
load_lyric();
meow_lyric_view_free(lv);
}
static void l5_hit_line(void)
{
printf("L5 点行 seek 命中 = 布局一致\n");
meow_theme_set_mode(MEOW_THEME_DARK);
meow_lyric_view *lv = meow_lyric_view_create(0, 100, 480, 500, lw, NULL); /* 视图中线=350 */
assert(lv);
meow_lyric_view_set_pos(lv, 2000); /* cur = idx1 Second(有翻译) */
meow_lyric_view_vis vis[MEOW_LYRIC_VIEW_MAX_ROWS];
int cnt = meow_lyric_view_visible(lv, 2000, vis, MEOW_LYRIC_VIEW_MAX_ROWS);
CHECK(cnt > 0, "可见条目数 > 0");
/* 命中必须与绘制布局一致:点每个可见块内子行,命中该块 ent */
int consistent = 1;
for (int k = 0; k < cnt && consistent; k++) {
int yb = vis[k].y, yh = vis[k].bh * 44;
for (int dy = 0; dy < yh && consistent; dy += 4) { /* 块内等距采样 */
int hit = meow_lyric_view_hit_line(lv, 240, yb + dy);
if (hit != vis[k].ent) consistent = 0;
}
}
CHECK(consistent, "命中=布局(块内逐点命中 ent 一致)");
/* 点翻译行(当前行块内下一子行)归本行,不误触下一行 */
int ci = -1;
for (int k = 0; k < cnt; k++) if (vis[k].ent == 1) { ci = k; break; }
CHECK(ci >= 0, "当前行在可见块中");
if (ci >= 0 && vis[ci].bh > vis[ci].rw) { /* 有翻译行 */
int ty = vis[ci].y + vis[ci].rw * 44 + 10; /* 翻译子行内 */
CHECK(meow_lyric_view_hit_line(lv, 240, ty) == 1, "点翻译行归本行(Second)不误触下一行");
}
/* 点可见块外(视图内)→ 就近取整(顶部/底部可见行) */
CHECK(meow_lyric_view_hit_line(lv, 240, 101) == vis[0].ent, "视图内顶缝→就近取第一可见块");
CHECK(meow_lyric_view_hit_line(lv, 240, 599) == vis[cnt - 1].ent, "视图内底缝→就近取最后可见块");
/* 视图外 → 未命中 */
CHECK(meow_lyric_view_hit_line(lv, 240, 50) == -1, "视图外(y=50)未命中");
CHECK(meow_lyric_view_hit_line(lv, 490, 300) == -1, "视图外(x=490)未命中");
/* widget 命中路径:命中当前行(有翻译)→ 触发 on_seek 回调 line_time(1)=2000 */
g_seek_ms = -1;
meow_lyric_view_set_on_seek(lv, seek_cb, NULL);
if (ci >= 0) {
int cy = vis[ci].y + 2; /* 当前行原文块内 */
CHECK(meow_widget_hit(meow_lyric_view_widget(lv), 240, cy) == 1, "widget hit 命中当前行");
CHECK(g_seek_ms == 2000, "on_seek 回调 line_time(Second)=2000");
} else {
CHECK(1, "on_seek 校验跳过(无当前行块)");
}
meow_lyric_view_free(lv);
}
static void l6_theme_switch(void)
{
printf("L6 主题换色(set_colors 覆盖 / NULL 恢复默认)\n");
meow_theme_set_mode(MEOW_THEME_DARK);
meow_lyric_view *lv = meow_lyric_view_create(0, 0, 480, 720, lw, NULL);
assert(lv);
uint32_t def_cur = meow_lyric_view_cur_color(lv); /* dark=0xFFFFFF */
/* 覆盖:活动行红、灰阶自定义、scrim 自定义 */
meow_lyric_view_colors c; memset(&c, 0, sizeof c);
c.scrim = 0x1E1E22; c.cur = 0xFF0000; c.scrim_alpha = 255;
c.gray[0] = 0xFF0000; c.gray[1] = 0xCC0000; c.gray[2] = 0x990000;
c.gray[3] = 0x660000; c.gray[4] = 0x440000; c.gray[5] = 0x220000;
meow_lyric_view_set_colors(lv, &c);
CHECK(meow_lyric_view_cur_color(lv) == 0xFF0000, "set_colors 覆盖活动行为红");
CHECK(meow_lyric_view_line_color(lv, 3) == 0x660000, "set_colors 覆盖灰阶(3)=0x660000");
CHECK(meow_lyric_view_line_color(lv, 5) == 0x220000, "set_colors 覆盖灰阶(5)=0x220000");
/* 恢复默认:NULL → 回主题 onSurface 纯白 */
meow_lyric_view_set_colors(lv, NULL);
CHECK(meow_lyric_view_cur_color(lv) == def_cur, "set_colors(NULL) 恢复主题默认");
meow_lyric_view_free(lv);
}
static void l7_scrim_pixel(void)
{
printf("L7 scrim 像素渲染(draw 落地)\n");
meow_theme_set_mode(MEOW_THEME_DARK);
meow_lyric_view *lv = meow_lyric_view_create(0, 0, 480, 720, lw, NULL);
assert(lv);
/* 不透明 scrim → 背底像素应为 scrim 色(RGB565 舍入容差) */
meow_lyric_view_colors c; memset(&c, 0, sizeof c);
c.scrim = 0x203040; c.scrim_alpha = 255;
meow_lyric_view_set_colors(lv, &c);
meow_lyric_view_set_pos(lv, 2000);
static const char *lrc =
"[00:01.00]First\n[00:02.00]Second\n[00:03.00]Third\n[00:04.00]Fourth\n";
meow_lyric_parse(lrc, NULL);
meow_ui_clear(0);
meow_widget *w = meow_lyric_view_widget(lv);
meow_widget_draw(w);
CHECK(px_near(240, 350, 0x20, 0x30, 0x40), "视图中线像素≈scrim 0x203040(文本打桩不影响 scrim)");
CHECK(px_near(50, 50, 0x20, 0x30, 0x40), "视图左上像素≈scrim 0x203040");
meow_lyric_view_free(lv);
}
int main(void)
{
setbuf(stdout, NULL);
setenv("MEOW_UI_DUMP", "/tmp/lyricview.ppm", 1);
meow_ui_opts o = { 480, 720 };
assert(meow_ui_init(&o) == 0);
meow_ui_activity_push(0x53);
load_lyric();
l1_white_gray();
l2_translation_block();
l3_above_anchor();
l4_wrap_cjk_west();
l5_hit_line();
l6_theme_switch();
l7_scrim_pixel();
meow_ui_activity_pop();
printf(g_fail ? "\nRESULT: %d FAIL(S)\n" : "\nRESULT: ALL PASS\n", g_fail);
return g_fail;
}
+2 -2
View File
@@ -42,8 +42,8 @@ patch_v2/meow_audiod/meow_audiod|/usr/bin/meow_audiod|b122c9b81880b00f705bca30e4
patch_v2/meow_audiod/meow_audio|/usr/bin/meow_audio|891ab26475ef9e9931bf9147d265dce4|audiod 用户侧 CLI M2.97(+monitor 实时电平表/文本电平条;status 补 rms/peak)
patch_v2/meow_powermgr/meow_powermgr|/usr/bin/meow_powermgr|1ef2f8afc07acc7aa09dee9f7149aba8|功耗管理专项服务期2 M6.1(期1+history/top/events+mp2731 current_now 实测充电功率+WiFi 未关联档/BT rfkill 细化+系数路径 /data/meow/powermgr/;IPC 0xD0-D7)
patch_v2/meow_powermgr/meow_powermgr|/usr/bin/meow_power|1ef2f8afc07acc7aa09dee9f7149aba8|meow_powermgr 的 CLI 形态(同二进制双形态,argv[0]=meow_power 即 CLI:status/estimate/model/history/top/events,policy 期3桩)
patch_v2/meow_netmgrd/meow_netmgrd|/usr/bin/meow_netmgrd|20243ea1a800b29badefebb395603aaf|网络管理服务期3 M6.1(期1 接口层+期2 连接普查/DNS 代理+期3 策略引擎防自锁/SOCK_DESTROY 实测 no-op 记档/面板白名单/WiFi save_config;IPC 0xE0-EB;agent-276)
patch_v2/meow_netmgrd/meow_netmgrd|/usr/bin/meow_net|20243ea1a800b29badefebb395603aaf|meow_netmgrd 的 CLI 形态(同二进制双形态,argv[0]=meow_net 即 CLI:status/iface/wifi/bt/rfkill/apps/conns/top/dns/policy/help)
patch_v2/meow_netmgrd/meow_netmgrd|/usr/bin/meow_netmgrd|f5fcba32fbe1537a595757e68d16c13c|网络管理服务期5 M6.27(流量统计细化:实时速率去lo uplink_totals+sock_diag TCP公/内网字节 pub_bytes;agent-389)
patch_v2/meow_netmgrd/meow_netmgrd|/usr/bin/meow_net|f5fcba32fbe1537a595757e68d16c13c|meow_netmgrd 的 CLI 形态(同二进制双形态,argv[0]=meow_net 即 CLI:status/iface/wifi/bt/rfkill/apps/conns/top/dns/policy/help)
# ---- 开机 init 脚本(etc/init.d,数字决定启动顺序,按文件名自然序)----
patch_v2/meow_sysui/S70meow_sysui|/etc/init.d/S70meow_sysui|e435affcb56499e5229728c6ae2524b9|sysui 自启 M2.85(launcher.conf enabled=1+迁移)