第 30 章 播放队列、随机、循环、记忆播放

本章目标

  • 让播放队列成为一等公民。
  • 实现四种模式:顺序、列表循环、单曲循环、随机。
  • 做"上次播放位置记忆"。

一、队列位于前后端哪一端

两派:

  • 纯前端:Zustand store 存队列,Rust 只知道当前歌。好处:灵活;坏处:多窗口同步要 emit。
  • 后端同步:Rust 维护队列,前端 subscribe。好处:多窗口一致;坏处:IPC 频繁。

CloudTone 采用后端主导 + 事件广播。

二、Rust Queue

// src-tauri/src/core/audio/queue.rs
use rand::seq::SliceRandom;

#[derive(Clone, Copy, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum PlayMode { Sequence, ListLoop, SingleLoop, Shuffle }

pub struct Queue {
    pub items: Vec<i64>,     // song ids
    pub index: usize,
    pub mode: PlayMode,
    shuffle_history: Vec<usize>,
}

impl Queue {
    pub fn replace(&mut self, songs: Vec<i64>, start_id: Option<i64>) {
        self.items = songs;
        self.index = start_id.and_then(|id| self.items.iter().position(|&x| x == id)).unwrap_or(0);
    }
    pub fn current(&self) -> Option<i64> { self.items.get(self.index).copied() }
    pub fn next(&mut self) -> Option<i64> {
        if self.items.is_empty() { return None; }
        match self.mode {
            PlayMode::SingleLoop => self.current(),
            PlayMode::Shuffle => {
                let mut rng = rand::thread_rng();
                let idxs: Vec<_> = (0..self.items.len()).filter(|i| *i != self.index).collect();
                self.shuffle_history.push(self.index);
                self.index = *idxs.choose(&mut rng).unwrap_or(&self.index);
                self.current()
            }
            PlayMode::Sequence => {
                if self.index + 1 < self.items.len() { self.index += 1; self.current() } else { None }
            }
            PlayMode::ListLoop => {
                self.index = (self.index + 1) % self.items.len();
                self.current()
            }
        }
    }
    pub fn prev(&mut self) -> Option<i64> {
        if self.items.is_empty() { return None; }
        if self.mode == PlayMode::Shuffle {
            if let Some(last) = self.shuffle_history.pop() { self.index = last; }
        } else if self.index > 0 { self.index -= 1; }
        else if self.mode == PlayMode::ListLoop { self.index = self.items.len() - 1; }
        self.current()
    }
}

三、Player 接入 Queue

把 Queue 放到 InnerPlayer,或者同级 AppState 里。Command:

#[tauri::command] #[specta::specta]
pub async fn queue_set(state: tauri::State<'_, AppState>, ids: Vec<i64>, start_id: Option<i64>) -> Result<(), String> {
    let mut q = state.queue.lock().await;
    q.replace(ids, start_id);
    if let Some(id) = q.current() {
        drop(q);
        player_play_internal(state, id).await?;
    }
    Ok(())
}

#[tauri::command] #[specta::specta]
pub async fn queue_next(state: tauri::State<'_, AppState>) -> Result<(), String> {
    let mut q = state.queue.lock().await;
    if let Some(id) = q.next() { drop(q); player_play_internal(state, id).await?; }
    Ok(())
}

#[tauri::command] #[specta::specta]
pub async fn queue_set_mode(state: tauri::State<'_, AppState>, mode: PlayMode) -> Result<(), String> {
    state.queue.lock().await.mode = mode; Ok(())
}

当播放 Ended 事件到来,后端自动调用 queue_next_internal 并 emit。

四、记忆播放

启动时从 DB kv 表读上次状态:

kv:
  last_song_id  -> "42"
  last_position -> "123.4"
  last_mode     -> "shuffle"

setup 里:

let last = sqlx::query_scalar::<_, String>("SELECT value FROM kv WHERE key = 'last_song_id'").fetch_optional(&pool).await.ok().flatten();
// 根据 last 加载并 pause

播放每 10 秒把当前 position 写回 kv,下次启动时 seek 到上次位置。

五、UI:队列面板

// NowPlayingPanel.tsx 加 Queue Tab
const { queue, index } = usePlayer();

<div>
  {queue.map((song, i) => (
    <div key={song.id} className={cn("p-2 rounded flex items-center gap-2", i === index && "bg-white/10")}>
      <span className="w-5 text-text-tertiary">{i + 1}</span>
      <div className="flex-1 truncate">{song.title}</div>
      <span className="text-text-secondary text-xs">{song.artist}</span>
    </div>
  ))}
</div>

拖拽排序:用 @dnd-kit/core。第 34 章详讲。

本章小结

  • Queue 主导在 Rust,一致性强。
  • 四种模式覆盖常见场景。
  • 记忆位置让回归用户零成本继续。

动手时刻

  • 实现 queue_set / queue_next,UI 按钮触发。
  • 切到随机模式,试试手感。

下一章:歌词。