use mineral_model::Song;
use mineral_protocol::{AdvanceKind, PlayCursor, PlayMode};
use rand::seq::SliceRandom;
use crate::state::State;
pub(crate) const QUEUE_CAP: usize = 9999;
pub(crate) fn next_index(st: &State) -> Option<usize> {
let len = st.queue.len();
if len == 0 {
return None;
}
let not_vetoed = |idx: &usize| !st.prefetch_vetoed.contains(idx);
if let PlayCursor::Detached { resume_at } = st.cursor {
return match st.play_mode {
PlayMode::Sequential => (resume_at..len).find(not_vetoed),
PlayMode::RepeatAll | PlayMode::Shuffle | PlayMode::RepeatOne => (0..len)
.map(|offset| (resume_at + offset) % len)
.find(not_vetoed),
};
}
let cur = st.cursor.anchor().min(len - 1);
if st.play_mode == PlayMode::RepeatOne && not_vetoed(&cur) {
return Some(cur);
}
match st.play_mode {
PlayMode::Sequential => ((cur + 1)..len).find(not_vetoed),
PlayMode::RepeatAll | PlayMode::Shuffle | PlayMode::RepeatOne => (1..=len)
.map(|offset| (cur + offset) % len)
.find(not_vetoed),
}
}
pub(crate) fn prev_index(st: &State) -> Option<usize> {
let len = st.queue.len();
if len == 0 {
return None;
}
if let PlayCursor::Detached { resume_at } = st.cursor {
return match st.play_mode {
PlayMode::Sequential => (resume_at > 0).then(|| resume_at - 1),
PlayMode::RepeatAll | PlayMode::Shuffle => Some((resume_at + len - 1) % len),
PlayMode::RepeatOne => Some(resume_at.min(len - 1)),
};
}
let cur = st.cursor.anchor().min(len - 1);
match st.play_mode {
PlayMode::Sequential => (cur > 0).then(|| cur - 1),
PlayMode::RepeatAll | PlayMode::Shuffle => Some((cur + len - 1) % len),
PlayMode::RepeatOne => Some(cur),
}
}
pub(crate) fn next_in_queue(st: &State) -> Option<Song> {
next_index(st).and_then(|i| st.queue.get(i).cloned())
}
pub(crate) fn advance_next(st: &mut State) -> Option<Song> {
let idx = next_index(st)?;
let song = st.queue.get(idx).cloned()?;
st.cursor = PlayCursor::InQueue(idx);
st.advance = Some((song.id.clone(), AdvanceKind::Next));
Some(song)
}
pub(crate) fn advance_prev(st: &mut State) -> Option<Song> {
let idx = prev_index(st)?;
let song = st.queue.get(idx).cloned()?;
st.cursor = PlayCursor::InQueue(idx);
st.advance = Some((song.id.clone(), AdvanceKind::Prev));
Some(song)
}
pub(crate) fn apply_play_mode(st: &mut State, new: PlayMode) {
let old = st.play_mode;
if old == new {
return;
}
mineral_log::info!(target: "player", old = ?old, new = ?new, "play mode changed");
st.play_mode = new;
match (old == PlayMode::Shuffle, new == PlayMode::Shuffle) {
(false, true) => enter_shuffle(st),
(true, false) => exit_shuffle(st),
_ => {}
}
st.notify_subscribers();
}
fn reanchor_id(st: &State) -> Option<mineral_model::SongId> {
match st.cursor {
PlayCursor::InQueue(_) => st.current_song.as_ref().map(|s| s.id.clone()),
PlayCursor::Detached { resume_at } => st.queue.get(resume_at).map(|s| s.id.clone()),
}
}
fn restore_anchor(st: &mut State, anchor: Option<mineral_model::SongId>, swap_to_front: bool) {
let found = anchor.and_then(|id| st.queue.iter().position(|s| s.id == id));
if swap_to_front && let Some(pos) = found {
st.queue.swap(0, pos);
}
let at = if swap_to_front {
found.map(|_| 0)
} else {
found
};
st.cursor = match st.cursor {
PlayCursor::InQueue(_) => PlayCursor::InQueue(at.unwrap_or(0)),
PlayCursor::Detached { .. } => PlayCursor::Detached {
resume_at: at.unwrap_or(st.queue.len()),
},
};
}
pub(crate) fn enter_shuffle(st: &mut State) {
if st.queue.is_empty() {
return;
}
let original = st.queue.clone();
let anchor = reanchor_id(st);
st.queue.shuffle(&mut rand::rng());
restore_anchor(st, anchor, true);
st.original_queue = Some(original);
st.bump_queue();
}
pub(crate) fn exit_shuffle(st: &mut State) {
let Some(original) = st.original_queue.take() else {
return;
};
let anchor = reanchor_id(st);
st.queue = original;
restore_anchor(st, anchor, false);
st.bump_queue();
}
pub(crate) fn insert_next(st: &mut State, songs: &[Song]) -> bool {
let before = st.queue.len();
let count = songs.len();
if count == 0 {
return false;
}
if count > QUEUE_CAP.saturating_sub(before) {
mineral_log::debug!(target: "player", mode = "next", count, before, after = before, cap = QUEUE_CAP, "queue batch rejected: capacity exceeded");
return false;
}
let orig_at = original_insert_pos(st);
let pos = after_current_pos(st);
st.queue.splice(pos..pos, songs.iter().cloned());
if let Some(orig) = st.original_queue.as_mut() {
let at = orig_at.min(orig.len());
orig.splice(at..at, songs.iter().cloned());
}
st.bump_queue();
mineral_log::info!(target: "player", mode = "next", count, before, after = st.queue.len(), "queue batch inserted");
true
}
pub(crate) fn after_current_pos(st: &State) -> usize {
let at = match st.cursor {
PlayCursor::InQueue(cur) => cur + 1,
PlayCursor::Detached { resume_at } => resume_at,
};
at.min(st.queue.len())
}
fn original_insert_pos(st: &State) -> usize {
let Some(orig) = st.original_queue.as_ref() else {
return 0;
};
match st.cursor {
PlayCursor::InQueue(_) => st
.current_song
.as_ref()
.and_then(|cur| orig.iter().position(|s| s.id == cur.id))
.map_or(orig.len(), |i| i + 1),
PlayCursor::Detached { resume_at } => st
.queue
.get(resume_at)
.and_then(|next| orig.iter().position(|s| s.id == next.id))
.unwrap_or(orig.len()),
}
}
pub(crate) fn append(st: &mut State, songs: &[Song]) -> bool {
let before = st.queue.len();
let count = songs.len();
if count == 0 {
return false;
}
if count > QUEUE_CAP.saturating_sub(before) {
mineral_log::debug!(target: "player", mode = "append", count, before, after = before, cap = QUEUE_CAP, "queue batch rejected: capacity exceeded");
return false;
}
st.queue.extend_from_slice(songs);
if let Some(orig) = st.original_queue.as_mut() {
orig.extend_from_slice(songs);
}
st.bump_queue();
mineral_log::info!(target: "player", mode = "append", count, before, after = st.queue.len(), "queue batch appended");
true
}
#[cfg(test)]
mod tests {
use mineral_protocol::{AdvanceKind, PlayCursor, PlayMode};
use mineral_test::song;
use super::{QUEUE_CAP, advance_next, advance_prev, append, insert_next, next_index};
use crate::state::State;
fn state_with_mode(mode: PlayMode) -> State {
let mut st = State::empty();
st.queue = vec![song("a"), song("b"), song("c")];
st.cursor = PlayCursor::InQueue(0);
st.play_mode = mode;
st
}
fn detached_state(mode: PlayMode) -> State {
let mut st = State::empty();
st.queue = vec![song("b"), song("c")];
st.current_song = Some(song("a"));
st.cursor = PlayCursor::Detached { resume_at: 0 };
st.play_mode = mode;
st
}
#[test]
fn detached_resumes_at_successor_itself() {
for mode in [PlayMode::Sequential, PlayMode::RepeatAll, PlayMode::Shuffle] {
let st = detached_state(mode);
assert_eq!(next_index(&st), Some(0), "{mode:?} 悬空时应落在接续点本身");
}
}
#[test]
fn detached_repeat_one_yields_to_successor() {
let st = detached_state(PlayMode::RepeatOne);
assert_eq!(next_index(&st), Some(0), "被摘出的当前曲不该继续循环");
}
#[test]
fn detached_past_tail_stops() {
let mut st = detached_state(PlayMode::Sequential);
st.queue = vec![song("b")];
st.cursor = PlayCursor::Detached { resume_at: 1 };
assert_eq!(next_index(&st), None, "接续点越过队尾应停止");
}
#[test]
fn vetoed_next_is_skipped() {
for mode in [PlayMode::Sequential, PlayMode::RepeatAll] {
let mut st = state_with_mode(mode);
st.prefetch_vetoed = vec![1];
assert_eq!(next_index(&st), Some(2), "{mode:?} 应越过被否决的 b 落到 c");
}
}
#[test]
fn all_following_vetoed() {
let mut st = state_with_mode(PlayMode::Sequential);
st.prefetch_vetoed = vec![1, 2];
assert_eq!(next_index(&st), None, "顺序模式候选耗尽应无下一首");
let mut st = state_with_mode(PlayMode::RepeatAll);
st.prefetch_vetoed = vec![1, 2];
assert_eq!(
next_index(&st),
Some(0),
"环回模式转一圈落回当前曲(唯一非否决候选)"
);
}
#[test]
fn repeat_one_vetoed_falls_through() {
let mut st = state_with_mode(PlayMode::RepeatOne);
assert_eq!(next_index(&st), Some(0), "未否决时原地循环");
st.prefetch_vetoed = vec![0];
assert_eq!(next_index(&st), Some(1), "循环曲被否决应顺出到 b");
}
#[test]
fn advance_next_honors_veto() -> color_eyre::Result<()> {
let mut st = state_with_mode(PlayMode::Sequential);
st.prefetch_vetoed = vec![1];
let next = advance_next(&mut st).ok_or_else(|| color_eyre::eyre::eyre!("应有下一首"))?;
assert_eq!(next.id, song("c").id, "Fallback 推进应越过被否决的 b");
assert_eq!(st.cursor, PlayCursor::InQueue(2));
Ok(())
}
#[test]
fn advance_writes_kind_account() -> color_eyre::Result<()> {
let mut st = state_with_mode(PlayMode::Sequential);
let _ = advance_next(&mut st).ok_or_else(|| color_eyre::eyre::eyre!("应有下一首"))?;
assert_eq!(st.advance, Some((song("b").id, AdvanceKind::Next)));
let _ = advance_prev(&mut st).ok_or_else(|| color_eyre::eyre::eyre!("应有上一首"))?;
assert_eq!(st.advance, Some((song("a").id, AdvanceKind::Prev)));
Ok(())
}
#[test]
fn advance_without_target_writes_nothing() {
let mut st = State::empty();
st.queue = vec![song("a")];
st.cursor = PlayCursor::InQueue(0);
assert!(advance_next(&mut st).is_none());
assert!(advance_prev(&mut st).is_none());
assert_eq!(st.advance, None, "没推进就不该留下档位账");
}
#[test]
fn queue_capacity_is_capped() {
for enqueue in [append, insert_next] {
let mut st = State::empty();
st.queue = (0..QUEUE_CAP - 2).map(|i| song(&i.to_string())).collect();
let before = st.queue.clone();
let version = st.queue_version;
assert!(!enqueue(&mut st, &[song("a"), song("b"), song("c")]));
assert_eq!(st.queue, before, "剩余容量不足时不得截取部分入队");
assert_eq!(st.queue_version, version);
assert!(enqueue(&mut st, &[song("a"), song("b")]));
assert_eq!(st.queue.len(), QUEUE_CAP);
assert_eq!(st.queue_version, version.next(), "整组只发布一次队列变更");
assert!(!enqueue(&mut st, &[song("overflow")]));
assert_eq!(st.queue.len(), QUEUE_CAP);
assert_eq!(st.queue_version, version.next());
}
}
#[test]
fn under_capacity_still_enqueues() {
for enqueue in [append, insert_next] {
for mode in [PlayMode::Sequential, PlayMode::Shuffle] {
for cursor in [
PlayCursor::InQueue(0),
PlayCursor::Detached { resume_at: 0 },
] {
let mut st = State::empty();
st.play_mode = mode;
st.cursor = cursor;
let batch = [song("a"), song("b"), song("a")];
let version = st.queue_version;
assert!(enqueue(&mut st, &batch));
assert_eq!(st.queue, batch);
assert_eq!(st.cursor, cursor);
assert_eq!(st.current_song, None);
assert_eq!(st.queue_version, version.next());
assert!(!enqueue(&mut st, &[]));
assert_eq!(st.queue, batch);
assert_eq!(st.queue_version, version.next());
}
}
}
}
#[test]
fn detached_batches_preserve_successor_and_shuffle_order() {
for resume_at in [0, 1, 2] {
let mut st = detached_state(PlayMode::Shuffle);
st.cursor = PlayCursor::Detached { resume_at };
st.original_queue = Some(vec![song("c"), song("b")]);
assert!(insert_next(&mut st, &[song("x"), song("y"), song("x")]));
assert_eq!(next_index(&st), Some(resume_at));
assert_eq!(
st.queue.get(resume_at..resume_at + 3),
Some([song("x"), song("y"), song("x")].as_slice())
);
assert!(append(&mut st, &[song("z"), song("z")]));
assert_eq!(
st.queue.get(st.queue.len() - 2..),
Some([song("z"), song("z")].as_slice())
);
assert_eq!(st.current_song, Some(song("a")));
assert_eq!(st.cursor, PlayCursor::Detached { resume_at });
super::exit_shuffle(&mut st);
let expected = match resume_at {
0 => vec![
song("c"),
song("x"),
song("y"),
song("x"),
song("b"),
song("z"),
song("z"),
],
1 => vec![
song("x"),
song("y"),
song("x"),
song("c"),
song("b"),
song("z"),
song("z"),
],
_ => vec![
song("c"),
song("b"),
song("x"),
song("y"),
song("x"),
song("z"),
song("z"),
],
};
assert_eq!(st.queue, expected);
assert_eq!(super::next_in_queue(&st), Some(song("x")));
assert_eq!(st.current_song, Some(song("a")));
}
}
#[test]
fn no_veto_keeps_existing_semantics() {
assert_eq!(next_index(&state_with_mode(PlayMode::Sequential)), Some(1));
assert_eq!(next_index(&state_with_mode(PlayMode::RepeatAll)), Some(1));
assert_eq!(next_index(&state_with_mode(PlayMode::RepeatOne)), Some(0));
let mut tail = state_with_mode(PlayMode::Sequential);
tail.cursor = PlayCursor::InQueue(2);
assert_eq!(next_index(&tail), None, "顺序到尾无下一首");
let mut wrap = state_with_mode(PlayMode::RepeatAll);
wrap.cursor = PlayCursor::InQueue(2);
assert_eq!(next_index(&wrap), Some(0), "环回到 0");
}
}