1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
//! 起播、切歌结算与媒体信息富化,统一记录播放语境和收听结果。
use std::sync::atomic::Ordering;
use mineral_model::{Song, SongId};
use mineral_protocol::{AdvanceKind, PlayCursor, PlaybackOrigin};
use mineral_task::{ChannelFetchKind, Priority, TaskKind};
use super::PlayerCore;
impl PlayerCore {
/// 建立一首歌的播放状态并启动媒体解析。
///
/// **不结算上一首**:自然 EOF / next / prev / stop 各路径已在调用前按各自语义
/// (eof / skip / stop)结算;直接改播入口(client `PlaySong` / 脚本 `Play`)须先调
/// [`Self::settle_interrupted`],否则被打断曲经 recorder 的自愈防线按近似值兜底。
///
/// # Params:
/// - `song`: 要播放的歌
/// - `play_origin`: 起播来源(埋点 provenance:显式点播 / 自动接续 / 脚本)
/// - `actor`: 发起方(用户按键 / 脚本 / daemon 自治;与 origin 独立)
pub fn play_song(
&self,
song: &Song,
play_origin: mineral_stats::PlayOrigin,
actor: mineral_stats::Actor,
) {
mineral_log::info!(
target: "player",
song_id = song.id.as_str(),
title = %song.name,
"play song"
);
// 这里只取消旧歌词拉取;播放实例由下方 slot 生命周期负责取消。
self.inner
.scheduler
.cancel_where(|k| matches!(k, TaskKind::ChannelFetch(ChannelFetchKind::Lyrics { .. })));
self.inner.audio.stop();
let local_hit = crate::resolve::resolve_local(
&self.inner.media_cache,
self.inner.music_dir.as_deref(),
song,
self.inner.playback_quality,
);
let origin = local_hit
.as_ref()
.map_or(PlaybackOrigin::Remote, |hit| hit.origin);
let slot = crate::playback_instance::PlaybackSlot::new(song.id.clone());
{
let mut st = self.inner.state.lock();
if let Some(current) = st.current_slot.take() {
current.cancel();
}
drop(st.take_prefetch());
st.current_song = Some(song.clone());
// 进入档位:顺序推进入口(advance_next / advance_prev)已按目标曲写好 Next / Prev,
// 留着;其余入口(点播 / 替换队列 / 会话恢复)都是「直接跳到某一首」,记随机访问。
st.advance = Some((
song.id.clone(),
st.advance
.take()
.filter(|(id, _)| id == &song.id)
.map_or(AdvanceKind::RandomAccess, |(_, kind)| kind),
));
// 仅当游标尚未指向本曲时才按身份 first-match 定位(列表点歌入口)。
// 顺序推进入口(advance_next/advance_prev)已把游标钉到精确下标,这里
// 必须保留——否则队列里有重复曲时,first-match 会把下标拽回首个副本,两首交替
// 的重复曲互相指回对方、无限循环跳不出去。
// 本曲在队列里找得到即离开悬空态;找不到(播队列外的歌)则保持原游标不动。
let already_positioned = st
.cursor
.queue_index()
.and_then(|at| st.queue.get(at))
.is_some_and(|s| s.id == song.id);
if !already_positioned && let Some(idx) = st.queue.iter().position(|s| s.id == song.id)
{
st.cursor = PlayCursor::InQueue(idx);
}
st.current_slot = Some(slot.clone());
st.media_info = None;
st.direct_media = None;
st.play_origin = Some(origin);
st.current_lyrics = None;
st.current_lyrics_song_id = None;
st.bump_current();
}
// 埋点:起播语境快照(origin / actor 由调用点穿透;context 经 take_play_context
// 消费 per-song 覆盖或继承队列级语境;format 等 resolved 快照随后经 enrich 补;
// 时钟异常拿不出起播时刻则本次不记)。
let play_mode = self.with_state(|st| st.play_mode);
let context = self.take_play_context(&song.id);
if let Some(pending) = crate::pending_from_start(
song.clone(),
crate::stats_play_mode(play_mode),
song.duration_ms.and_then(|d| i64::try_from(d).ok()),
origin,
play_origin,
actor,
context,
) {
self.inner.stats.play_started(pending);
}
// 对齐 finished_seq,防止 audio.stop() 极端时序下被旧 seq 误触发。
let seq = self.inner.audio.snapshot().track_finished_seq;
self.inner
.last_seen_finished_seq
.store(seq, Ordering::Relaxed);
crate::playback::start_current(self, song.clone(), slot, local_hit);
mineral_log::debug!(target: "player", song_id = song.id.as_str(), source = ?song.source(), "submit Lyrics task");
self.inner.scheduler.submit(
TaskKind::ChannelFetch(ChannelFetchKind::Lyrics {
song_id: song.id.clone(),
}),
Priority::User,
);
self.spawn_save_session();
}
/// 取该曲的起播语境:优先消费 per-song 覆盖(插队散曲,取后移除),否则继承队列级
/// 语境。所有起播路径(play_song / gapless adopt)统一走这里,防止某条路径绕过覆盖
/// 把插队曲记成队列归属。
///
/// # Params:
/// - `id`: 起播曲 id
///
/// # Return:
/// 该曲应记入 plays 的语境
pub(crate) fn take_play_context(&self, id: &SongId) -> mineral_stats::QueueContext {
self.with_state(|st| {
st.context_overrides
.remove(&id.qualified())
.unwrap_or_else(|| st.queue_context.clone())
})
}
/// 结算被直接改播打断的在播曲(按 skip):直接改播入口(client `PlaySong` / 脚本
/// `Play`)在调 [`Self::play_song`] 前用。next / prev / EOF / stop 各有自己的结算,
/// 不走这里。无在播曲时 no-op。
///
/// 必须在新曲 `play_song` **之前**调用:结算取的是被打断曲的实时播放位置,且
/// recorder 按 FIFO 先消化本结算再开新 pending。
pub(crate) fn settle_interrupted(&self) {
let Some(old) = self.with_state(|st| st.current_song.clone()) else {
return;
};
let position_ms = self.inner.audio.snapshot().position_ms;
self.spawn_on_played(
old.id.clone(),
mineral_stats::FinishReason::Skip,
position_ms,
);
self.inner
.notify
.track_finished(&old, mineral_protocol::FinishReason::Skip);
}
/// 以已生效媒体事实富化在播行的音频列。
///
/// Current 与 gapless promotion 都必须调用本入口,使 hook 顶换标记进入同一 stats snapshot。
///
/// # Params:
/// - `info`: 已打开媒体的展示元信息。
/// - `origin`: 实际资源位置,可能已被脚本改写。
pub(crate) fn enrich_from_media_info(
&self,
info: &mineral_model::PlaybackMediaInfo,
origin: PlaybackOrigin,
) {
self.inner.stats.enrich_play_audio(
mineral_stats::PlayAudioSnapshot {
audio_format: info.format.clone(),
bitrate_bps: info.bitrate_bps.map(i64::from),
quality: Some(self.inner.playback_quality),
bit_depth: info.bit_depth.map(i64::from),
substituted: info.substituted,
},
origin,
);
}
/// 异步上报一次播放打点(fire-and-forget,不阻塞播放)。
///
/// # Params:
/// - `id`: 歌曲
/// - `reason`: 结束原因(自然播完 eof / 被切 skip;stop / error 走各自站点)
/// - `listen_ms`: 本次收听毫秒
pub(crate) fn spawn_on_played(
&self,
id: SongId,
reason: mineral_stats::FinishReason,
listen_ms: u64,
) {
// 埋点:结算在播行(起播被 gate 掉时 actor 无 pending、自动忽略)。
self.inner
.stats
.play_ended(reason, i64::try_from(listen_ms).unwrap_or(i64::MAX));
let Some(channel) = self.channel_for(id.namespace()) else {
return;
};
let channel = channel.clone();
// channel 契约的上报语义是完成布尔:eof 视为完整播完,其余为中断。
let completed = matches!(reason, mineral_stats::FinishReason::Eof);
tokio::spawn(async move {
if let Err(e) = channel.on_played(&id, completed, listen_ms).await {
mineral_log::warn!(target: "player", error = mineral_log::chain(&e), "on_played 打点失败");
}
});
}
}