feat(ncm §21): 边下边播阶段1+2——stream_src流式源+decoder双路open_cb+下载线程预缓冲/stall联动+P2 HTTP空闲超时+cache fd写者+stream_sim全矩阵PCM金标准(0真实请求,未部署)

This commit is contained in:
MeowOS committed 2026-09-01 18:23:17 +08:00
1 parent dbee37e1d6
commit 9e25db77cc
18 files changed
+1335 -136

No files matched your search

+222 -83
View File
@@ -1,5 +1,5 @@
/* play/player_manager.c — 播放线程:URL 获取(全档表逐级降级)→缓存(命中直接播/否则流式落盘)→
* minimp3/dr_flac 解码→audio_out 直写。UI 不卡(网络+解码全在此线程)。
/* play/player_manager.c — 播放线程:URL 获取(全档表逐级降级)→缓存(命中直接播/否则边下边播)→
* minimp3/dr_flac 解码(stream_src 回调)→audio_out 直写。UI 不卡(网络+解码全在此线程)。
*
* 模型:一个持久的播放线程。外层循环取「目标曲目」,run_song() 负责一首歌的
* 「下载(缓冲) → 解码 → 输出 → 结束/被指令打断」。暂停=冻结(停读停写停推 pos,状态置 PAUSED);
@@ -11,6 +11,12 @@
* 音频缓存键 = 歌曲ID+音质档位(用户定案 NCM_CACHE_DESIGN §2.5),旧无档位键温和兼容。
* §20:音质体系全量——用户档超会员权限不硬拒(钳顶后逐级降级,eff_level 回填供播放页
* 「已降级至 xx」标注;下一首回到用户档);fee∈{1,4} 且非会员 → NCM_ERR_VIP 不发请求。
* §21:边下边播(NCM_STREAM_PLAY 方案 A)——下载拆到独立线程(write(2) 直写 .tmp +
* stream_src 推进可读边界),解码经 ncm_dec_open_src 的 read_cb(严格 fread 语义,F7)
* pread 同一文件;预缓冲 ≥128KB 才 open(minimp3 MINIMP3_IO_SIZE 强制门槛,F3);
* 下载完成由播放线程 commit(rename);缓存命中用 final=size 的静态 stream_src(P1:
* 不再 mmap 整文件);http 30s 绝对 deadline 已改空闲超时(P2)。.part 续传/LRU 防淘汰
* 留阶段 3。
*/
#include "../ncm_app_internal.h"
#include <stdio.h>
@@ -19,6 +25,7 @@
#include <unistd.h>
#include <sys/stat.h>
#include "decoder.h"
#include "stream_src.h"
static pthread_t g_thr;
static int g_thr_on;
@@ -92,6 +99,9 @@ void player_manager_play(int song_idx)
g_play.dl_total = 0;
g_play.seq++;
g_play.eff_level = -1; /* 新播放意图:生效档未定(song_local_file 回填) */
g_play.underruns = 0; /* §21:边下边播统计随新播放意图清零 */
g_play.underrun_ms = 0;
g_play.stalled = 0;
pthread_mutex_unlock(&g_play.mu);
}
@@ -127,6 +137,9 @@ void player_manager_next(int dir)
g_play.dl_total = 0;
g_play.seq++;
g_play.eff_level = -1; /* 切歌:生效档随新曲重估(下首恢复用户档,§20) */
g_play.underruns = 0;
g_play.underrun_ms = 0;
g_play.stalled = 0;
}
pthread_mutex_unlock(&g_play.mu);
}
@@ -143,13 +156,77 @@ void player_manager_stop(void)
}
/* ---- 每首歌的播放体(在外层循环内调用)---- */
static int download_cb(void *ctx, const unsigned char *data, size_t len)
/* 边下边播每首曲的源对象(§21):读端 stream_src + 下载路径写端/线程。
* run_song 栈上分配,下载线程 join 后才返回,生命周期安全。 */
#define PREBUFFER_BYTES (128 * 1024) /* 预缓冲门槛 = minimp3 MINIMP3_IO_SIZE(F3 实测强制) */
typedef struct {
stream_src_t *src; /* 读端(下载路径 written 由下载线程推进;缓存命中=静态源) */
ncm_cache_writer_t *w; /* 下载路径写端(commit/abort 归播放线程);缓存命中=NULL */
pthread_t dl_thr;
int dl_thr_on;
int dl_rc; /* 下载线程结果(NCM_OK / 负错误码) */
char url[1024];/* 下载线程用(song_local_file 栈上 u.url 生命周期不够,拷贝) */
size_t maxb; /* 下载限幅 */
int64_t total; /* 标称大小(song_url size;0=未知;mp3 seek 分母) */
} song_src_t;
/* http 体回调(下载线程上下文;ncm_http 每次投递 ≤4KB):write(2) 直写落盘 +
* 锁内推进 written + broadcast 唤醒读端 + 进度反馈。 */
static int stream_dl_cb(void *ctx, const unsigned char *data, size_t len)
{
/* 下载进度反馈(UI「缓冲中… N%」,§18):回调在播放线程,锁内仅累加。 */
song_src_t *ss = (song_src_t *)ctx;
if (stream_src_aborted(ss->src)) return 1; /* 切歌/停止:中止 http */
if (ncm_cache_write(ss->w, data, len) != NCM_OK) return 1;
stream_src_note_written(ss->src, (uint64_t)len);
pthread_mutex_lock(&g_play.mu);
g_play.dl_done += (int64_t)len;
pthread_mutex_unlock(&g_play.mu);
return ncm_cache_write((ncm_cache_writer_t *)ctx, data, len);
return 0;
}
static void *dl_thread(void *arg)
{
song_src_t *ss = (song_src_t *)arg;
ncm_http_req_t req;
memset(&req, 0, sizeof req);
req.url = ss->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 = 15000; /* 绝对段(连接/握手/头)上限;body 空闲超时 15s(P2) */
ncm_http_resp_t resp;
int rc = ncm_http_download(&req, stream_dl_cb, ss, ss->maxb, &resp);
if (rc == NCM_OK && resp.status != 200) rc = NCM_ERR_HTTP;
ss->dl_rc = rc;
/* 结束通知:成功 final=全长;失败/中止 final=截断点+failed(读端播到截断点自然结束,F5) */
stream_src_finish(ss->src, rc == NCM_OK ? 0 : rc);
return NULL;
}
/* 收尾(播放线程,解码循环退出后调用):中止在途下载并 join → commit(rename 转正) 或
* abort(unlink .tmp)。阶段 2 语义:中断即丢弃;.part 续传/LRU 防淘汰留阶段 3。 */
static void song_src_dispose(song_src_t *ss)
{
if (ss->dl_thr_on) {
stream_src_abort(ss->src); /* 已完成则无副作用;在途则 cb 返回 NZ 中止 */
pthread_join(ss->dl_thr, NULL);
ss->dl_thr_on = 0;
}
if (ss->w) {
if (ss->dl_rc == NCM_OK) {
/* 下载完整:commit 转正进 LRU 池(rename 不影响读端已开 fd,此处已近曲终) */
if (ncm_cache_commit(ss->w) == 0)
ncm_cache_evict(&g_st.ctx.cache); /* 写后检查:新落盘可能推过上限 */
} else {
ncm_cache_abort(ss->w);
}
ss->w = NULL;
}
if (ss->src) { stream_src_close(ss->src); ss->src = NULL; }
}
/* 缓存键(用户定案 NCM_CACHE_DESIGN §2.5):歌曲ID + 音质档位 → "song_<id>_<level>.<ext>"。
@@ -160,50 +237,63 @@ static void audio_cache_key(char *out, size_t sz, const char *id, int level)
snprintf(out, sz, "%s_%s", id, ncm_api_level_key(level));
}
/* 得到本地音频路径 + 文件大小。命中缓存直接返回;否则 URL 降级 + 流式落缓存。
* 返回 NCM_OK 或负错误码。s 为播放曲快照(run_song 物化,与窗口解耦 §17)。
/* 切歌竞态护栏(§18):物化/下载/预缓冲期间用户可能已切歌——只有仍是当前目标且处于
* BUFFER(本次播放意图未过期)才继续,否则直接回外层(外层会以新 cur 重跑 run_song)。 */
static int still_current(int idx)
{
pthread_mutex_lock(&g_play.mu);
int ok = (g_play.cur_song == idx) && (g_play.state == NCM_PC_BUFFER);
pthread_mutex_unlock(&g_play.mu);
return ok;
}
/* 得到本地音频源(§21 边下边播)。命中缓存 → 静态 stream_src(final=size,read 不阻塞,
* 顺带修 P1:不再 mmap 整文件);否则 URL 降级 → 起下载线程写 .tmp → 预缓冲 ≥128KB 即返回
* (下载在后台继续,解码经 stream_src read_cb 跟播)。
* 返回 NCM_OK 或负错误码;成功时 ss->src 就绪、ss->total 与 *bytes 回填(下载未完成时
* total=song_url 标称 size,0=未知)。s 为播放曲快照(run_song 物化,与窗口解耦 §17)。
* §20 权限/会员:
* - fee 门禁(BIZ_NOTES §3 社区口径):fee∈{1,4}(VIP 才能听/付费专)且当前非会员
* → 直接 NCM_ERR_VIP,**不发 song_url 请求**(省一次请求,防风控);fee=0/8 放行
* (8=免费听低音质,播放允许)。本地缓存命中优先于门禁(缓存可播不拦)。
* - VIP 档上限:用户档超会员权限不硬拒,播放时先钳到会员允许的最高档再逐级降级
* (免去注定空 URL 的高档请求);实际生效档回填 g_play.eff_level。 */
static int song_local_file(const ncm_song_t *s, char *path, size_t psz, int64_t *bytes)
static int song_local_file(const ncm_song_t *s, int idx, song_src_t *ss,
char *path, size_t psz, int64_t *bytes)
{
memset(ss, 0, sizeof *ss);
const char *id = s->id;
char key[64];
audio_cache_key(key, sizeof key, id, g_level);
/* 缓存命中 → 离线直接播(新键两扩展名都试;档位=用户设定档,降级产物也归该档键) */
/* 缓存命中 → 静态 stream_src(新键两扩展名都试;档位=用户设定档,降级产物也归该档键) */
int hit = 0;
if (ncm_cache_lookup(&g_st.ctx.cache, key, "flac", path, psz) == NCM_OK ||
ncm_cache_lookup(&g_st.ctx.cache, key, "mp3", path, psz) == NCM_OK) {
struct stat st;
if (stat(path, &st) == 0) {
*bytes = (int64_t)st.st_size;
/* 生效档推断(缓存键=用户档,降级产物归用户档键,无法精确还原):
* mp3 实体 + 用户档 > 极高 → 必为降级产物,标 EXHIGH;其余按用户档显示。
* (flac 实体视为与用户档一致;hires→lossless 档内降级会高估显示,已知取舍。) */
const char *dot = strrchr(path, '.');
int eff = g_level;
if (dot && ncm_streq(dot, "mp3") && g_level > NCM_LEVEL_EXHIGH)
eff = NCM_LEVEL_EXHIGH;
pthread_mutex_lock(&g_play.mu);
g_play.eff_level = eff;
pthread_mutex_unlock(&g_play.mu);
return NCM_OK;
}
hit = 1;
} else if (ncm_cache_lookup(&g_st.ctx.cache, id, "mp3", path, psz) == NCM_OK) {
/* 旧键温和兼容(§19 前存量:无档位后缀的 song_<id>[.]mp3,含历史双点名):
* 命中即播,不主动改名/删除,由 LRU 自然淘汰。 */
hit = 2;
}
/* 旧键温和兼容(§19 前存量:无档位后缀的 song_<id>[.]mp3,含历史双点名):命中即播,
* 不主动改名/删除,由 LRU 自然淘汰。 */
if (ncm_cache_lookup(&g_st.ctx.cache, id, "mp3", path, psz) == NCM_OK) {
if (hit) {
struct stat st;
if (stat(path, &st) == 0) {
*bytes = (int64_t)st.st_size;
pthread_mutex_lock(&g_play.mu);
g_play.eff_level = (g_level > NCM_LEVEL_EXHIGH) ? NCM_LEVEL_EXHIGH : g_level;
pthread_mutex_unlock(&g_play.mu);
return NCM_OK;
}
if (stat(path, &st) != 0) return NCM_ERR_NOENT;
if (stream_src_open_static(&ss->src, path, (uint64_t)st.st_size) != 0)
return NCM_ERR_IO;
ss->total = (int64_t)st.st_size;
*bytes = ss->total;
/* 生效档推断(缓存键=用户档,降级产物归用户档键,无法精确还原):
* mp3 实体 + 用户档 > 极高 → 必为降级产物,标 EXHIGH;其余按用户档显示。
* (flac 实体视为与用户档一致;hires→lossless 档内降级会高估显示,已知取舍。) */
const char *dot = strrchr(path, '.');
int eff = g_level;
if (dot && ncm_streq(dot, "mp3") && g_level > NCM_LEVEL_EXHIGH)
eff = NCM_LEVEL_EXHIGH;
pthread_mutex_lock(&g_play.mu);
g_play.eff_level = eff;
pthread_mutex_unlock(&g_play.mu);
return NCM_OK;
}
/* fee 门禁:VIP 才能听(1=VIP 歌曲 / 4=付费数字专辑)且非会员 → 不请求 url。
@@ -222,50 +312,63 @@ static int song_local_file(const ncm_song_t *s, char *path, size_t psz, int64_t
if (!u.url[0]) return NCM_ERR_NOTFOUND;
pthread_mutex_lock(&g_play.mu);
g_play.eff_level = (eff >= 0) ? eff : want;
g_play.dl_total = (u.size > 0) ? u.size : 0; /* 缓冲进度% 分母(0=未知只转菊花) */
pthread_mutex_unlock(&g_play.mu);
/* 落盘扩展名:type 权威;type 缺失时看 url 扩展名,默认 mp3 */
const char *ext = "mp3";
if (ncm_streq(u.type, "flac")) ext = "flac";
else if (!u.type[0] && strstr(u.url, ".flac")) ext = "flac";
pthread_mutex_lock(&g_play.mu);
g_play.dl_total = (u.size > 0) ? u.size : 0; /* 缓冲进度% 分母(0=未知只转菊花) */
pthread_mutex_unlock(&g_play.mu);
/* 写前检查:池大小超限先按 LRU 淘汰(此线程尚未开 temp,无在途文件被误删风险) */
ncm_cache_evict(&g_st.ctx.cache);
/* 流式下载到缓存 temp → commit(稳妥者:先落盘再播) */
ncm_cache_writer_t *w = NULL;
rc = ncm_cache_open(&g_st.ctx.cache, key, ext, &w);
/* 边下边播(§21):下载线程写 .tmp(write(2) 直写),解码线程 pread 同一文件 */
rc = ncm_cache_open(&g_st.ctx.cache, key, ext, &ss->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;
if (stream_src_open_stream(&ss->src, ncm_cache_writer_tmp_path(ss->w)) != 0) {
ncm_cache_abort(ss->w); ss->w = NULL;
return NCM_ERR_IO;
}
ncm_strlcpy(path, ncm_cache_writer_tmp_path(ss->w), psz); /* 日志用 */
ss->total = (u.size > 0) ? u.size : 0;
/* 上限:Hi-Res flac 单曲可达数百 MB(192k/24bit 5min ≈345MB),flac 上限 1GB;mp3 维持 64MB。
* size 可信时按 size 限幅(防超额),未知按格式默认上限。 */
size_t cap = (ext[0] == 'f') ? (1024u << 20) : (64u << 20);
size_t maxb = (u.size > 0 && (uint64_t)u.size < cap) ? (size_t)u.size : cap;
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;
ss->maxb = (u.size > 0 && (uint64_t)u.size < cap) ? (size_t)u.size : cap;
ncm_strlcpy(ss->url, u.url, sizeof ss->url);
if (pthread_create(&ss->dl_thr, NULL, dl_thread, ss) != 0) {
ncm_cache_abort(ss->w); ss->w = NULL;
stream_src_close(ss->src); ss->src = NULL;
return NCM_ERR_IO;
}
rc = ncm_cache_commit(w);
if (rc != 0) return rc;
/* 写后检查:新落盘可能把池推过上限 → 再按 LRU 淘汰最旧(刚写的 mtime 最新,存活) */
ncm_cache_evict(&g_st.ctx.cache);
if (ncm_cache_lookup(&g_st.ctx.cache, key, ext, path, psz) != NCM_OK)
return NCM_ERR_NOENT;
struct stat st;
if (stat(path, &st) == 0) *bytes = (int64_t)st.st_size;
ss->dl_thr_on = 1;
/* 预缓冲:≥128KB(minimp3 强制门槛,F3)才放行 open_cb;final/failed 提前出。
* still_current 护栏覆盖本阻塞点:等待期间切歌/停止 → 中止下载静默放弃。 */
for (;;) {
int wr = stream_src_wait(ss->src, PREBUFFER_BYTES, 100);
if (wr != 0) break;
if (g_app_quit || !still_current(idx)) {
stream_src_abort(ss->src);
pthread_join(ss->dl_thr, NULL); ss->dl_thr_on = 0;
ncm_cache_abort(ss->w); ss->w = NULL;
stream_src_close(ss->src); ss->src = NULL;
return NCM_ERR_IO; /* 调用方 still_current 复核后静默返回 */
}
}
if (stream_src_aborted(ss->src) ||
(stream_src_failed(ss->src) && stream_src_written(ss->src) < PREBUFFER_BYTES)) {
/* 中止,或首开声前失败(written < 预缓冲门槛):abort .tmp
*(阶段 2 不保留不重试;重试策略留阶段 3)。已下过门槛的断流照常放行——
* 读端播到截断点自然结束(F5)。 */
int frc = stream_src_failed(ss->src);
pthread_join(ss->dl_thr, NULL); ss->dl_thr_on = 0;
ncm_cache_abort(ss->w); ss->w = NULL;
stream_src_close(ss->src); ss->src = NULL;
return frc ? frc : NCM_ERR_NET;
}
/* final < 门槛(小文件/截断):照样放行,open_cb 读到的就是全部 */
*bytes = ss->total > 0 ? ss->total : (int64_t)stream_src_written(ss->src);
return NCM_OK;
}
@@ -278,17 +381,7 @@ static void set_err(int err, const char *msg)
pthread_mutex_unlock(&g_play.mu);
}
/* 切歌竞态护栏(§18):物化/下载期间用户可能已切歌——只有仍是当前目标且处于 BUFFER
* (本次播放意图未过期)才继续,否则直接回外层(外层会以新 cur 重跑 run_song)。 */
static int still_current(int idx)
{
pthread_mutex_lock(&g_play.mu);
int ok = (g_play.cur_song == idx) && (g_play.state == NCM_PC_BUFFER);
pthread_mutex_unlock(&g_play.mu);
return ok;
}
/* 一首歌:下载→解码→输出,直到结束或被打断。返回后由外层处理下一首。 */
/* 一首歌:源就绪(缓存/下载线程+预缓冲)→解码→输出,直到结束或被打断。返回后由外层处理下一首。 */
static void run_song(int idx)
{
/* §17 滑窗兼容:物化目标曲(窗内命中 / 盘缓存 / song_detail 单曲)并整体快照进
@@ -304,35 +397,43 @@ static void run_song(int idx)
g_play.play_song_valid = 1;
pthread_mutex_unlock(&g_play.mu);
song_src_t ss;
int64_t file_bytes = 0;
char path[512];
int rc = song_local_file(&s, path, sizeof path, &file_bytes);
int rc = song_local_file(&s, idx, &ss, path, sizeof path, &file_bytes);
if (rc != NCM_OK) {
if (!still_current(idx)) return; /* 下载期间已切歌:静默放弃(缓存已落盘不亏) */
if (!still_current(idx)) return; /* 下载期间已切歌:静默放弃(已落盘部分不亏) */
const char *m = (rc == NCM_ERR_VIP) ? "VIP only (fee gate)"
: (rc == NCM_ERR_NOTFOUND) ? "no stream (VIP/copyright)"
: (rc == NCM_ERR_AUTH) ? "session expired" : "load failed";
set_err(rc, m);
return;
}
if (!still_current(idx)) return; /* 下载完成但已切歌:勿开解码/勿动状态 */
if (!still_current(idx)) { /* 预缓冲完成但已切歌:勿开解码/勿动状态 */
song_src_dispose(&ss);
return;
}
ncm_strlcpy(g_path, path, sizeof g_path);
g_file_bytes = file_bytes;
int64_t dur = s.duration_ms > 0 ? s.duration_ms : 0;
/* 解码分发(§19):magic 探测 fLaC→dr_flac / 其它→minimp3;rate/ch/bits 按实际流。 */
/* 解码分发(§19/§21):stream_src 上 magic 探测 fLaC→dr_flac / 其它→minimp3(open_cb
* SEEK_TO_BYTE|DO_NOT_SCAN);rate/ch/bits 按实际流。 */
ncm_decoder_t *dec = NULL;
int rate = 0, ch = 0, bits = 0;
int64_t total_frames = 0;
if (ncm_dec_open(&dec, path, &rate, &ch, &bits, &total_frames) != 0) {
if (ncm_dec_open_src(&dec, ss.src, &rate, &ch, &bits, &total_frames) != 0) {
song_src_dispose(&ss);
if (!still_current(idx)) return; /* open 期间(flac 元数据等待)已切歌 */
set_err(NCM_ERR_IO, "decode open failed"); return;
}
if (dur <= 0 && total_frames > 0 && rate > 0) dur = total_frames * 1000 / rate;
/* 音频输出:按实际流参数开(flac ≤16bit→S16,>16bit→S32;audiod 侧格式归一+原生率跟随) */
if (audio_out_open(rate, ch, bits) != 0) {
ncm_dec_close(dec); set_err(NCM_ERR_IO, "audio open failed"); return;
ncm_dec_close(dec); song_src_dispose(&ss);
set_err(NCM_ERR_IO, "audio open failed"); return;
}
/* 状态→播放 */
@@ -351,6 +452,8 @@ static void run_song(int idx)
int64_t frames_done = 0; /* 已输出帧(每通道) */
int need_seek_ms = -1;
int paused_edge = 0; /* 暂停沿:进暂停已向 audiod 发 pause */
int stalled_edge = 0; /* §21 停滞沿:已对 audiod pause(1),待数据恢复 pause(0) */
int underruns_seen = 0;
for (;;) {
if (g_app_quit) break;
@@ -385,7 +488,8 @@ static void run_song(int idx)
}
if (need_seek_ms >= 0) {
/* seek:flac 精确帧 / mp3 关+重开按字节近似(decoder 内处理);输出重开 */
/* seek:flac 精确帧 / mp3 字节近似(MP3D_SEEK_TO_BYTE 直接 seek,无需关重开);
* 目标超已下载区时 read_cb 自然阻塞等待(F4),输出重开 */
meow_log(MEOW_LOG_INFO, "play: seek idx=%d -> %dms", idx, need_seek_ms);
audio_out_close();
if (ncm_dec_seek_ms(dec, need_seek_ms, g_file_bytes, dur) != 0) {
@@ -397,8 +501,19 @@ static void run_song(int idx)
continue;
}
/* §21 停滞联动:read_cb 单次等待 >1s(audiod 流饥饿风险)→ 先对 audiod pause(1)
* 保住流(暂停路径现状已有),数据恢复后 pause(0)(流死自动重开)。 */
if (stream_src_take_stalled(ss.src) && !stalled_edge) {
stalled_edge = 1;
meow_log(MEOW_LOG_INFO, "play: stalled idx=%d -> audio pause(1)", idx);
audio_out_pause(1);
pthread_mutex_lock(&g_play.mu);
g_play.stalled = 1;
pthread_mutex_unlock(&g_play.mu);
}
int64_t frames = ncm_dec_read(dec, pcm, PCM_CHUNK_FRAMES);
if (frames == 0) { /* 播完 */
if (frames == 0) { /* 播完(下载中断时=截断点,F5) */
meow_log(MEOW_LOG_INFO, "play: done idx=%d frames=%lld", idx, (long long)frames_done);
break;
}
@@ -409,6 +524,28 @@ static void run_song(int idx)
break;
}
frames_done += frames;
if (stalled_edge) { /* 数据恢复:解除停滞 pause */
stalled_edge = 0;
meow_log(MEOW_LOG_INFO, "play: stall recovered idx=%d -> audio pause(0)", idx);
if (audio_out_pause(0) != 0) { set_err(NCM_ERR_IO, "audio resume failed"); break; }
pthread_mutex_lock(&g_play.mu);
g_play.stalled = 0;
pthread_mutex_unlock(&g_play.mu);
}
/* underrun 统计落 g_play(§21,设置页/日志可见;变化才持锁写) */
{
int u; uint64_t wm;
stream_src_stats(ss.src, &u, &wm);
if (u != underruns_seen) {
underruns_seen = u;
pthread_mutex_lock(&g_play.mu);
g_play.underruns = u;
g_play.underrun_ms = wm;
pthread_mutex_unlock(&g_play.mu);
meow_log(MEOW_LOG_INFO, "play: underrun idx=%d count=%d wait=%llums",
idx, u, (unsigned long long)wm);
}
}
/* 更新进度(~200ms 节流)*/
int64_t pos = rate > 0 ? frames_done * 1000 / rate : 0;
pthread_mutex_lock(&g_play.mu);
@@ -419,6 +556,8 @@ static void run_song(int idx)
audio_out_close();
ncm_dec_close(dec);
/* 下载完成由播放线程 commit(rename) / 中断 abort(阶段 2 语义,§21) */
song_src_dispose(&ss);
/* 若应继续播下一首且非因命令打断,由外层处理(外层见 cur==idx 且需自然结束) */
}