use std::collections::HashMap;
use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
use uuid::Uuid;
use crate::remote::downloads::{ByteFeed, DownloadStore};
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct QueueItemId(pub Uuid);
impl QueueItemId {
pub fn new() -> Self {
Self(Uuid::now_v7())
}
}
impl Default for QueueItemId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for QueueItemId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let hex = self.0.simple().to_string();
write!(f, "QId({})", &hex[hex.len() - 8..])
}
}
#[derive(Debug, Clone)]
pub struct Lookahead {
pub after: QueueItemId,
pub next: Option<QueueItemId>,
pub chosen: Option<(QueueItemId, PathBuf)>,
pub wrapped: bool,
pub boundary: usize,
pub after_pass: u64,
pub pass: u64,
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum Repeat {
#[default]
Off,
Queue,
One,
}
impl Repeat {
pub fn is_off(&self) -> bool {
*self == Repeat::Off
}
pub fn as_str(self) -> &'static str {
match self {
Repeat::Off => "off",
Repeat::Queue => "queue",
Repeat::One => "one",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"off" => Some(Repeat::Off),
"queue" => Some(Repeat::Queue),
"one" => Some(Repeat::One),
_ => None,
}
}
pub fn cycled(self) -> Self {
match self {
Repeat::Off => Repeat::Queue,
Repeat::Queue => Repeat::One,
Repeat::One => Repeat::Off,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct PlayMode {
pub shuffle: bool,
pub repeat: Repeat,
}
impl PlayMode {
fn to_bits(self) -> u8 {
let repeat = match self.repeat {
Repeat::Off => 0,
Repeat::Queue => 1,
Repeat::One => 2,
};
repeat << 1 | self.shuffle as u8
}
fn from_bits(bits: u8) -> Self {
Self {
shuffle: bits & 1 != 0,
repeat: match bits >> 1 {
1 => Repeat::Queue,
2 => Repeat::One,
_ => Repeat::Off,
},
}
}
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "camelCase")]
pub enum QueueMode {
#[default]
Keep,
InOrder,
Shuffled,
}
impl QueueMode {
pub fn is_keep(&self) -> bool {
*self == QueueMode::Keep
}
pub fn play_mode(self) -> Option<PlayMode> {
let shuffle = match self {
QueueMode::Keep => return None,
QueueMode::InOrder => false,
QueueMode::Shuffled => true,
};
Some(PlayMode {
shuffle,
repeat: Repeat::Off,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum SleepTimer {
After { minutes: u32 },
EndOfTrack,
EndOfRecord,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Sleep {
At {
unix_ms: u64,
},
EndOfTrack,
EndOfRecord,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum TrackKey<'a> {
Library(i64),
File(&'a std::path::Path),
}
impl PlaylistItem {
fn key(&self) -> TrackKey<'_> {
match self.db_id {
Some(id) => TrackKey::Library(id),
None => TrackKey::File(&self.path),
}
}
fn playable(&self) -> bool {
!matches!(self.state, ItemState::Failed(_))
}
}
#[derive(Debug, Clone, Default)]
pub struct PlayOrder {
upcoming: Vec<QueueItemId>,
next_pass: Option<Vec<QueueItemId>>,
history: Vec<QueueItemId>,
}
fn scatter(order: &mut Vec<QueueItemId>, mut fresh: Vec<QueueItemId>) {
if fresh.is_empty() {
return;
}
crate::helpers::shuffle(&mut fresh);
let Some(mut rng) = crate::helpers::Rng::seeded() else {
order.extend(fresh);
return;
};
let old = std::mem::take(order);
let (mut left, mut right) = (old.len(), fresh.len());
let (mut old, mut fresh) = (old.into_iter(), fresh.into_iter());
order.reserve(left + right);
while left + right > 0 {
if rng.below(left + right) < left {
order.extend(old.next());
left -= 1;
} else {
order.extend(fresh.next());
right -= 1;
}
}
}
fn follows(
items: &[PlaylistItem],
after: QueueItemId,
repeat: Repeat,
) -> Option<(Option<&PlaylistItem>, bool)> {
let at = items.iter().position(|item| item.id == after)?;
let playable = |item: &&PlaylistItem| !matches!(item.state, ItemState::Failed(_));
if repeat == Repeat::One && playable(&&items[at]) {
return Some((Some(&items[at]), false));
}
if let Some(next) = items[at + 1..].iter().find(playable) {
return Some((Some(next), false));
}
if repeat == Repeat::Off {
return Some((None, false));
}
Some((items[..=at].iter().find(playable), true))
}
const WAITING: u8 = 0x80;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum PlaybackState {
Stopped = 0,
Playing = 1,
Paused = 2,
}
impl PlaybackState {
pub fn from_u8(v: u8) -> Self {
match v {
1 => Self::Playing,
2 => Self::Paused,
_ => Self::Stopped,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TrackInfo {
pub id: QueueItemId,
pub path: PathBuf,
pub codec: String,
pub sample_rate: u32,
pub bit_depth: Option<u16>,
pub bitrate_kbps: Option<u32>,
pub channels: u16,
pub duration_ms: u64,
}
pub const STREAM_THRESHOLD: u64 = 256 * 1024;
pub const SEEK_SAFETY_MS: u64 = 2_000;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum ItemState {
#[default]
Pending,
Ready,
Failed(String),
}
#[derive(Debug, Clone)]
pub enum LoadState {
Pending,
Downloading {
path: PathBuf,
total: u64,
bytes_written: Arc<ByteFeed>,
},
Ready,
Failed(String),
}
impl LoadState {
pub fn of(item: &PlaylistItem, downloads: &DownloadStore) -> Self {
match &item.state {
ItemState::Ready => Self::Ready,
ItemState::Failed(reason) => Self::Failed(reason.clone()),
ItemState::Pending => match item.db_id.and_then(|id| downloads.live(id)) {
Some(live) => Self::Downloading {
path: live.source,
total: live.total,
bytes_written: live.written,
},
None => Self::Pending,
},
}
}
}
pub enum PlaybackSource {
Ready(PathBuf),
Streaming {
path: PathBuf,
bytes_written: Arc<crate::remote::downloads::ByteFeed>,
total: u64,
},
}
#[derive(Debug, Clone)]
pub struct PlaylistItem {
pub id: QueueItemId,
pub db_id: Option<i64>,
pub playlist_entry_id: Option<i64>,
pub path: PathBuf,
pub title: String,
pub artist: String,
pub album_artist: String,
pub album: String,
pub year: Option<String>,
pub codec: Option<String>,
pub track_number: Option<i64>,
pub disc: Option<i64>,
pub duration_ms: Option<u64>,
pub state: ItemState,
pub played: bool,
}
#[derive(Debug, Clone, Default)]
pub struct Playlist {
pub items: Vec<PlaylistItem>,
pub cursor: Option<QueueItemId>,
pub order: Option<PlayOrder>,
pub pass: u64,
}
impl Playlist {
fn find(&self, id: QueueItemId) -> Option<&PlaylistItem> {
self.items.iter().find(|item| item.id == id)
}
fn reconcile(&mut self) {
let Playlist {
items,
cursor,
order,
..
} = self;
let Some(order) = order.as_mut() else {
return;
};
let rows: HashMap<QueueItemId, &PlaylistItem> =
items.iter().map(|item| (item.id, item)).collect();
let mut seen: std::collections::HashSet<TrackKey> = items
.iter()
.filter(|item| item.played)
.map(PlaylistItem::key)
.chain(cursor.and_then(|c| rows.get(&c)).map(|item| item.key()))
.collect();
fn keep<'a>(
seen: &mut std::collections::HashSet<TrackKey<'a>>,
item: &'a PlaylistItem,
unplayed: bool,
) -> bool {
(!unplayed || !item.played) && item.playable() && seen.insert(item.key())
}
order
.upcoming
.retain(|id| rows.get(id).is_some_and(|item| keep(&mut seen, item, true)));
let fresh = items
.iter()
.filter(|item| keep(&mut seen, item, true))
.map(|item| item.id)
.collect();
scatter(&mut order.upcoming, fresh);
if let Some(next) = order.next_pass.as_mut() {
let mut seen = std::collections::HashSet::new();
next.retain(|id| {
rows.get(id)
.is_some_and(|item| keep(&mut seen, item, false))
});
let fresh = items
.iter()
.filter(|item| keep(&mut seen, item, false))
.map(|item| item.id)
.collect();
scatter(next, fresh);
}
order.history.retain(|id| rows.contains_key(id));
}
fn new_pass(&self, last: Option<QueueItemId>) -> Vec<QueueItemId> {
let mut seen = std::collections::HashSet::new();
let mut pass: Vec<QueueItemId> = self
.items
.iter()
.filter(|item| item.playable() && seen.insert(item.key()))
.map(|item| item.id)
.collect();
crate::helpers::shuffle(&mut pass);
let last = last.and_then(|id| self.find(id)).map(PlaylistItem::key);
if pass.len() > 1 && last.is_some() && self.find(pass[0]).map(PlaylistItem::key) == last {
let other =
crate::helpers::Rng::seeded().map_or(1, |mut rng| 1 + rng.below(pass.len() - 1));
pass.swap(0, other);
}
pass
}
fn follows(
&mut self,
after: Option<QueueItemId>,
ahead: bool,
repeat: Repeat,
) -> Option<(Option<QueueItemId>, bool)> {
let Some(order) = self.order.as_ref() else {
return match after {
Some(after) => follows(&self.items, after, repeat)
.map(|(next, wrapped)| (next.map(|item| item.id), wrapped)),
None => Some((
self.items
.iter()
.find(|item| item.playable())
.map(|item| item.id),
false,
)),
};
};
if let Some(after) = after {
let item = self.find(after)?;
if repeat == Repeat::One && item.playable() {
return Some((Some(after), false));
}
}
let playable = |id: &&QueueItemId| self.find(**id).is_some_and(PlaylistItem::playable);
let from = |order: &[QueueItemId]| {
after
.and_then(|after| order.iter().position(|id| *id == after))
.map_or(0, |at| at + 1)
};
if ahead {
let Some(next) = order.next_pass.as_ref() else {
return Some((None, false));
};
let at = from(next);
let found = next[at..].iter().chain(&next[..at]).find(playable).copied();
return Some((found, true));
}
let upcoming = &order.upcoming;
if let Some(next) = upcoming[from(upcoming)..].iter().find(playable) {
return Some((Some(*next), false));
}
if repeat == Repeat::Off {
return Some((None, false));
}
if order.next_pass.is_none() {
let pass = self.new_pass(after.or(self.cursor));
self.order.as_mut().expect("checked above").next_pass = Some(pass);
}
let next = self.order.as_ref()?.next_pass.as_ref()?;
let playable = |id: &&QueueItemId| self.find(**id).is_some_and(PlaylistItem::playable);
Some((next.iter().find(playable).copied(), true))
}
fn place_cursor(&mut self, id: Option<QueueItemId>) {
let Playlist {
items,
cursor,
order,
..
} = self;
*cursor = id;
let Some(id) = id else { return };
let Some(key) = items
.iter()
.find(|item| item.id == id)
.map(PlaylistItem::key)
else {
return;
};
let Some(order) = order.as_mut() else { return };
let keys: HashMap<QueueItemId, TrackKey> =
items.iter().map(|item| (item.id, item.key())).collect();
order.upcoming.retain(|row| keys.get(row) != Some(&key));
if order.history.last() != Some(&id) {
order.history.push(id);
}
}
fn move_on(&mut self, id: QueueItemId, pass: Option<u64>) {
let wrapped = match (&self.order, pass) {
(_, Some(pass)) => pass > self.pass,
(Some(order), None) => {
self.cursor != Some(id)
&& !order.upcoming.contains(&id)
&& order
.next_pass
.as_ref()
.is_some_and(|pass| pass.contains(&id))
}
(None, None) => {
let at = |id| self.items.iter().position(|item| item.id == id);
matches!((self.cursor.and_then(at), at(id)), (Some(from), Some(to)) if to < from)
}
};
if wrapped {
for item in &mut self.items {
item.played = false;
}
self.pass += 1;
if let Some(order) = self.order.as_mut() {
order.upcoming = order.next_pass.take().unwrap_or_default();
}
}
self.place_cursor(Some(id));
}
fn start_over_if_spent(&mut self) -> bool {
let mut playable = self.items.iter().filter(|item| item.playable()).peekable();
if playable.peek().is_none() || !playable.all(|item| item.played) {
return false;
}
for item in &mut self.items {
item.played = false;
}
self.pass += 1;
if let Some(order) = self.order.as_mut() {
order.upcoming.clear();
order.next_pass = None;
}
self.reconcile();
true
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueueEntryStatus {
Queued,
Playing,
Played,
Downloading,
PriorityPending,
Failed,
}
impl QueueEntryStatus {
pub fn at_cursor(state: &ItemState, transferring: bool) -> Self {
match state {
ItemState::Ready => Self::Playing,
ItemState::Failed(_) => Self::Failed,
ItemState::Pending if transferring => Self::Downloading,
ItemState::Pending => Self::PriorityPending,
}
}
}
#[derive(Debug, Clone)]
pub struct QueueEntry {
pub id: QueueItemId,
pub db_id: Option<i64>,
pub playlist_entry_id: Option<i64>,
pub path: PathBuf,
pub title: String,
pub artist: String,
pub album_artist: String,
pub album: String,
pub year: Option<String>,
pub codec: Option<String>,
pub track_number: Option<i64>,
pub disc: Option<i64>,
pub duration_ms: Option<u64>,
pub status: QueueEntryStatus,
pub download_progress: Option<(u64, u64)>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct VisibleQueueSnapshot {
pub entries: Vec<QueueEntry>,
pub finished_count: usize,
pub has_playing: bool,
pub queue_count: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct QueueReading {
pub id: QueueItemId,
pub db_id: Option<i64>,
pub status: QueueEntryStatus,
pub duration_ms: Option<u64>,
pub download_progress: Option<(u64, u64)>,
pub error: Option<String>,
}
#[derive(Debug)]
pub struct SharedPlayerState {
state: AtomicU8,
position_ms: AtomicU64,
timeline: std::sync::OnceLock<Arc<crate::audio::buffer::PlaybackTimeline>>,
track_info: parking_lot::RwLock<Option<TrackInfo>>,
playlist: parking_lot::RwLock<Playlist>,
playlist_version: AtomicU64,
content_version: AtomicU64,
pending_version: AtomicU64,
played_version: AtomicU64,
downloads: Arc<DownloadStore>,
quit_requested: AtomicBool,
metadata_refresh_pending: AtomicBool,
output_sample_rate: AtomicU64,
dsp: parking_lot::RwLock<Option<crate::audio::dsp::DspStatus>>,
renderer_clock: parking_lot::Mutex<Option<RendererClock>>,
renderer: parking_lot::RwLock<Option<crate::upnp::Output>>,
play_mode: AtomicU8,
sleep: parking_lot::RwLock<Option<Sleep>>,
sleep_fading: AtomicBool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RendererClock {
pub position_ms: u64,
pub running: Option<std::time::Instant>,
}
impl RendererClock {
pub fn now_ms(&self) -> u64 {
self.position_ms + self.running.map_or(0, |at| at.elapsed().as_millis() as u64)
}
}
impl SharedPlayerState {
pub fn new() -> Arc<Self> {
Arc::new(Self {
state: AtomicU8::new(PlaybackState::Stopped as u8),
position_ms: AtomicU64::new(0),
timeline: std::sync::OnceLock::new(),
track_info: parking_lot::RwLock::new(None),
playlist: parking_lot::RwLock::new(Playlist::default()),
playlist_version: AtomicU64::new(0),
content_version: AtomicU64::new(0),
pending_version: AtomicU64::new(0),
played_version: AtomicU64::new(0),
downloads: DownloadStore::new(),
quit_requested: AtomicBool::new(false),
metadata_refresh_pending: AtomicBool::new(false),
output_sample_rate: AtomicU64::new(0),
dsp: parking_lot::RwLock::new(None),
renderer_clock: parking_lot::Mutex::new(None),
renderer: parking_lot::RwLock::new(None),
play_mode: AtomicU8::new(0),
sleep: parking_lot::RwLock::new(None),
sleep_fading: AtomicBool::new(false),
})
}
fn transport(&self) -> (PlaybackState, bool) {
let bits = self.state.load(Ordering::Acquire);
(PlaybackState::from_u8(bits & !WAITING), bits & WAITING != 0)
}
pub fn playback_state(&self) -> PlaybackState {
self.transport().0
}
pub fn set_playback_state(&self, state: PlaybackState) {
self.set_transport(state, false);
}
pub fn is_waiting(&self) -> bool {
self.transport().1
}
pub fn wants_to_play(&self) -> bool {
matches!(
self.transport(),
(PlaybackState::Playing, _) | (PlaybackState::Stopped, true)
)
}
pub fn is_idle(&self) -> bool {
self.transport() == (PlaybackState::Stopped, false)
}
pub fn set_transport(&self, state: PlaybackState, waiting: bool) {
let bits = state as u8 | if waiting { WAITING } else { 0 };
if self.state.swap(bits, Ordering::AcqRel) != bits {
self.changed();
}
}
pub fn position_ms(&self) -> u64 {
let clock = *self.renderer_clock.lock();
if let Some(clock) = clock {
let at = clock.now_ms();
let duration = self.duration_ms();
return if duration > 0 { at.min(duration) } else { at };
}
if self.playback_state() != PlaybackState::Stopped
&& let Some(playhead) = self.timeline.get().and_then(|t| t.playhead())
{
return playhead.position_ms;
}
self.position_ms.load(Ordering::Acquire)
}
pub(crate) fn attach_timeline(&self, timeline: Arc<crate::audio::buffer::PlaybackTimeline>) {
let _ = self.timeline.set(timeline);
}
pub fn set_position_ms(&self, pos: u64) {
self.position_ms.store(pos, Ordering::Release);
}
pub fn playhead_moving(&self) -> bool {
let clock =
(*self.renderer_clock.lock()).or_else(|| self.timeline.get().and_then(|t| t.clock()));
match clock {
Some(clock) => clock.running.is_some(),
None => self.playback_state() == PlaybackState::Playing,
}
}
pub(crate) fn renderer_clock(&self) -> Option<RendererClock> {
*self.renderer_clock.lock()
}
pub(crate) fn set_renderer_clock(&self, clock: Option<RendererClock>) {
let mut guard = self.renderer_clock.lock();
if *guard != clock {
*guard = clock;
drop(guard);
self.changed();
}
}
pub fn renderer(&self) -> Option<crate::upnp::Output> {
self.renderer.read().clone()
}
pub(crate) fn set_renderer(&self, output: Option<crate::upnp::Output>) {
*self.renderer.write() = output;
self.changed();
}
pub(crate) fn update_renderer(&self, f: impl FnOnce(&mut crate::upnp::Output)) {
let changed = match self.renderer.write().as_mut() {
Some(out) => {
let before = out.clone();
f(out);
*out != before
}
None => false,
};
if changed {
self.changed();
}
}
pub fn track_info(&self) -> Option<TrackInfo> {
self.track_info.read().clone()
}
pub fn set_track_info(&self, info: Option<TrackInfo>) {
*self.track_info.write() = info;
self.changed();
}
pub fn seekable_ms(&self) -> u64 {
let Some(info) = self.track_info.read().clone() else {
return 0;
};
let pl = self.playlist.read();
let Some(item) = pl.items.iter().find(|item| item.id == info.id) else {
return info.duration_ms;
};
let LoadState::Downloading {
total,
bytes_written,
..
} = self.load_state(item)
else {
return info.duration_ms;
};
if info.duration_ms == 0 {
return 0;
}
let written = bytes_written.load(Ordering::Acquire);
let reached = if total > 0 && info.duration_ms > 0 {
((written as f64 / total as f64) * info.duration_ms as f64) as u64
} else if let Some(kbps) = info.bitrate_kbps.filter(|k| *k > 0) {
written.saturating_mul(8) / kbps as u64
} else {
return self.position_ms();
};
reached.saturating_sub(SEEK_SAFETY_MS).min(info.duration_ms)
}
pub fn duration_ms(&self) -> u64 {
let Some(info) = self.track_info.read().clone() else {
return 0;
};
if info.duration_ms > 0 {
return info.duration_ms;
}
self.playlist
.read()
.items
.iter()
.find(|item| item.id == info.id)
.and_then(|item| item.duration_ms)
.unwrap_or(0)
}
pub fn seek_ceiling_ms(&self) -> Option<u64> {
let duration = self.duration_ms();
if duration == 0 {
return None;
}
let seekable = self.seekable_ms();
(seekable < duration).then_some(seekable)
}
pub fn current_download_fraction(&self) -> Option<f64> {
let id = self.track_info.read().as_ref()?.id;
let pl = self.playlist.read();
pl.items
.iter()
.find(|item| item.id == id)
.and_then(|item| match self.load_state(item) {
LoadState::Downloading {
bytes_written,
total,
..
} => {
let written = bytes_written.load(Ordering::Acquire);
(total > 0).then(|| (written as f64 / total as f64).min(1.0))
}
_ => None,
})
}
pub fn request_quit(&self) {
self.quit_requested.store(true, Ordering::Release);
}
pub fn quit_requested(&self) -> bool {
self.quit_requested.load(Ordering::Acquire)
}
pub fn signal_metadata_refresh(&self) {
self.metadata_refresh_pending.store(true, Ordering::Release);
self.changed();
}
pub fn take_metadata_refresh(&self) -> bool {
self.metadata_refresh_pending
.compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
}
pub fn output_sample_rate(&self) -> Option<u32> {
match self.output_sample_rate.load(Ordering::Acquire) {
0 => None,
rate => Some(rate as u32),
}
}
pub fn set_output_sample_rate(&self, rate: u32) {
self.output_sample_rate
.store(u64::from(rate), Ordering::Release);
self.changed();
}
pub fn clear_output_sample_rate(&self) {
self.output_sample_rate.store(0, Ordering::Release);
}
pub fn dsp(&self) -> Option<crate::audio::dsp::DspStatus> {
self.dsp.read().clone()
}
pub fn set_dsp(&self, status: Option<crate::audio::dsp::DspStatus>) {
let mut dsp = self.dsp.write();
if *dsp != status {
*dsp = status;
drop(dsp);
self.changed();
}
}
pub fn play_mode(&self) -> PlayMode {
PlayMode::from_bits(self.play_mode.load(Ordering::Acquire))
}
pub fn set_play_mode(&self, mode: PlayMode) {
if self.play_mode.swap(mode.to_bits(), Ordering::AcqRel) != mode.to_bits() {
self.content_version.fetch_add(1, Ordering::AcqRel);
self.bump_version();
}
}
pub fn sleep(&self) -> Option<Sleep> {
*self.sleep.read()
}
pub fn set_sleep(&self, sleep: Option<Sleep>) {
let mut held = self.sleep.write();
if *held != sleep {
*held = sleep;
drop(held);
self.changed();
}
}
pub fn sleep_fading(&self) -> bool {
self.sleep_fading.load(Ordering::Acquire)
}
pub fn set_sleep_fading(&self, fading: bool) {
if self.sleep_fading.swap(fading, Ordering::AcqRel) != fading {
self.changed();
}
}
pub fn playlist_version(&self) -> u64 {
self.playlist_version.load(Ordering::Acquire)
}
fn bump_version(&self) {
self.playlist_version.fetch_add(1, Ordering::AcqRel);
self.changed();
}
pub fn content_version(&self) -> u64 {
self.content_version.load(Ordering::Acquire)
}
pub fn saved_version(&self) -> u64 {
self.content_version()
.wrapping_add(self.played_version.load(Ordering::Acquire))
}
fn bump_content(&self) {
self.playlist.write().reconcile();
self.content_version.fetch_add(1, Ordering::AcqRel);
self.pending_version.fetch_add(1, Ordering::AcqRel);
self.bump_version();
}
pub fn pending_version(&self) -> u64 {
self.pending_version.load(Ordering::Acquire)
}
pub fn downloads(&self) -> &Arc<DownloadStore> {
&self.downloads
}
fn load_state(&self, item: &PlaylistItem) -> LoadState {
LoadState::of(item, &self.downloads)
}
pub fn changed(&self) {
crate::signal::engine_changed().bump();
}
pub fn add_items(&self, items: Vec<PlaylistItem>) {
let mut pl = self.playlist.write();
pl.items.extend(items);
drop(pl);
self.bump_content();
}
pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
let mut pl = self.playlist.write();
let insert_at = match pl.items.iter().position(|item| item.id == after) {
Some(pos) => pos + 1,
None => pl.items.len(), };
for (i, item) in items.into_iter().enumerate() {
pl.items.insert(insert_at + i, item);
}
drop(pl);
self.bump_content();
}
pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
let mut pl = self.playlist.write();
for (id, new_path) in updates {
if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
item.path = new_path.clone();
}
}
drop(pl);
self.bump_content();
}
pub fn remove_item(&self, id: QueueItemId) {
let mut pl = self.playlist.write();
pl.items.retain(|item| item.id != id);
if pl.cursor == Some(id) {
pl.cursor = None;
}
drop(pl);
self.bump_content();
}
pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
let mut pl = self.playlist.write();
let Some(from) = pl.items.iter().position(|item| item.id == id) else {
return;
};
let item = pl.items.remove(from);
let Some(to) = pl.items.iter().position(|item| item.id == target) else {
let pos = from.min(pl.items.len());
pl.items.insert(pos, item);
return;
};
let insert_at = if after { to + 1 } else { to };
pl.items.insert(insert_at, item);
drop(pl);
self.bump_content();
}
pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
use std::collections::HashSet;
let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
let mut pl = self.playlist.write();
let mut remaining = Vec::with_capacity(pl.items.len());
let mut moved = Vec::with_capacity(ids.len());
for item in pl.items.drain(..) {
if id_set.contains(&item.id) {
moved.push(item);
} else {
remaining.push(item);
}
}
let insert_at = match remaining.iter().position(|item| item.id == target) {
Some(pos) => {
if after {
pos + 1
} else {
pos
}
}
None => remaining.len(),
};
for (i, item) in moved.into_iter().enumerate() {
remaining.insert(insert_at + i, item);
}
pl.items = remaining;
drop(pl);
self.bump_content();
}
pub fn set_cursor(&self, id: Option<QueueItemId>) {
let mut pl = self.playlist.write();
pl.place_cursor(id);
drop(pl);
self.bump_version();
}
pub fn pick(&self, id: QueueItemId) {
let mut pl = self.playlist.write();
pl.place_cursor(Some(id));
let replayed = pl.start_over_if_spent();
drop(pl);
if replayed {
self.played_version.fetch_add(1, Ordering::AcqRel);
}
self.bump_version();
}
pub fn move_on_to(&self, id: QueueItemId, pass: Option<u64>) {
let mut pl = self.playlist.write();
pl.move_on(id, pass);
drop(pl);
self.played_version.fetch_add(1, Ordering::AcqRel);
self.bump_version();
}
pub fn mark_played(&self, id: QueueItemId) {
let mut pl = self.playlist.write();
let Some(item) = pl.items.iter_mut().find(|item| item.id == id) else {
return;
};
if std::mem::replace(&mut item.played, true) {
return;
}
drop(pl);
self.played_version.fetch_add(1, Ordering::AcqRel);
self.bump_version();
}
pub fn set_shuffled(&self, on: bool) {
let mut pl = self.playlist.write();
if pl.order.is_some() == on {
return;
}
pl.order = on.then(PlayOrder::default);
let cursor = pl.cursor;
pl.reconcile();
pl.place_cursor(cursor);
if on && pl.start_over_if_spent() {
self.played_version.fetch_add(1, Ordering::AcqRel);
}
drop(pl);
self.pending_version.fetch_add(1, Ordering::AcqRel);
self.bump_version();
}
pub fn is_shuffled(&self) -> bool {
self.playlist.read().order.is_some()
}
#[cfg(test)]
pub(crate) fn upcoming(&self) -> Vec<QueueItemId> {
let pl = self.playlist.read();
pl.order
.as_ref()
.map(|o| o.upcoming.clone())
.unwrap_or_default()
}
pub fn is_empty(&self) -> bool {
self.playlist.read().items.is_empty()
}
pub fn cursor(&self) -> Option<QueueItemId> {
self.playlist.read().cursor
}
pub fn cursor_path(&self) -> Option<PathBuf> {
let pl = self.playlist.read();
let cursor = pl.cursor?;
pl.items
.iter()
.find(|item| item.id == cursor)
.map(|item| item.path.clone())
}
pub fn replace_playlist(
&self,
items: Vec<PlaylistItem>,
) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
let mut pl = self.playlist.write();
let old = std::mem::replace(&mut pl.items, items);
let cursor = pl.cursor.take();
drop(pl);
self.bump_content();
(old, cursor)
}
pub fn clear_playlist(&self) {
let mut pl = self.playlist.write();
pl.items.clear();
pl.cursor = None;
drop(pl);
self.bump_content();
}
pub fn advance_cursor_loadable(&self) -> Option<QueueItemId> {
let repeat = match self.play_mode().repeat {
Repeat::Off => Repeat::Off,
Repeat::Queue | Repeat::One => Repeat::Queue,
};
let mut pl = self.playlist.write();
let cursor = pl.cursor;
let (next, wrapped) = pl.follows(cursor, false, repeat)?;
let pass = pl.pass + u64::from(wrapped);
pl.move_on(next?, Some(pass));
drop(pl);
self.played_version.fetch_add(1, Ordering::AcqRel);
self.bump_version();
next
}
pub fn lookahead_after(&self, after_id: QueueItemId) -> Option<Lookahead> {
self.lookahead(after_id, None)
}
pub fn lookahead_from(&self, step: &Lookahead) -> Option<Lookahead> {
self.lookahead(step.chosen.as_ref()?.0, Some(step.pass))
}
fn lookahead(&self, after_id: QueueItemId, after_pass: Option<u64>) -> Option<Lookahead> {
let repeat = self.play_mode().repeat;
let mut pl = self.playlist.write();
let after_pass = after_pass.unwrap_or(pl.pass);
let ahead = after_pass > pl.pass;
let (next, wrapped) = pl.follows(Some(after_id), ahead, repeat)?;
let pass = pl.pass + u64::from(ahead || wrapped);
let next = next.and_then(|id| pl.find(id));
Some(Lookahead {
after: after_id,
next: next.map(|item| item.id),
chosen: next
.filter(|item| matches!(item.state, ItemState::Ready))
.map(|item| (item.id, item.path.clone())),
wrapped,
boundary: 0,
after_pass,
pass,
})
}
pub fn still_follows(&self, step: &Lookahead) -> bool {
let repeat = self.play_mode().repeat;
let mut pl = self.playlist.write();
let ahead = step.after_pass > pl.pass;
pl.follows(Some(step.after), ahead, repeat)
.is_some_and(|(next, _)| next == step.next)
}
pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
let mut pl = self.playlist.write();
if pl.order.is_some() {
let cursor = pl.cursor;
let history = &pl.order.as_ref()?.history;
let end =
history.len() - usize::from(cursor.is_some() && history.last() == cursor.as_ref());
let at = history[..end]
.iter()
.rposition(|id| pl.find(*id).is_some())?;
let id = history[at];
let path = pl.find(id)?.path.clone();
let order = pl.order.as_mut()?;
order.history.truncate(at + 1);
pl.cursor = Some(id);
drop(pl);
self.bump_version();
return Some((id, path));
}
let cursor_pos = match pl.cursor {
Some(cid) => pl.items.iter().position(|item| item.id == cid),
None => None,
};
let wraps = self.play_mode().repeat != Repeat::Off && pl.items.len() > 1;
let prev_pos = cursor_pos.and_then(|p| match p.checked_sub(1) {
None if wraps => Some(pl.items.len() - 1),
prev => prev,
});
match prev_pos {
Some(pos) => {
let item = &pl.items[pos];
let result = (item.id, item.path.clone());
pl.cursor = Some(item.id);
drop(pl);
self.bump_version();
Some(result)
}
None => None,
}
}
pub fn update_item_state(&self, id: QueueItemId, new_state: ItemState) {
let pending = new_state == ItemState::Pending;
let mut pl = self.playlist.write();
if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
item.state = new_state;
}
drop(pl);
if pending {
self.pending_version.fetch_add(1, Ordering::AcqRel);
}
self.bump_version();
}
pub fn item_state(&self, id: QueueItemId) -> Option<ItemState> {
let pl = self.playlist.read();
pl.items
.iter()
.find(|item| item.id == id)
.map(|item| item.state.clone())
}
pub fn update_item_metadata(
&self,
id: QueueItemId,
title: String,
artist: String,
album_artist: String,
album: String,
duration_ms: Option<u64>,
) {
let mut pl = self.playlist.write();
let mut retagged = false;
let mut retimed = false;
if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
if item.db_id.is_none() {
retagged = item.title != title
|| item.artist != artist
|| item.album_artist != album_artist
|| item.album != album;
item.title = title;
item.artist = artist;
item.album_artist = album_artist;
item.album = album;
}
if let Some(dur) = duration_ms
&& item.duration_ms != Some(dur)
{
item.duration_ms = Some(dur);
retimed = true;
}
}
drop(pl);
if retagged {
self.bump_content();
} else if retimed {
self.bump_version();
}
}
pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
let pl = self.playlist.read();
pl.items
.iter()
.find(|item| item.id == id)
.and_then(|item| match self.load_state(item) {
LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
LoadState::Downloading {
path,
total,
bytes_written,
} => {
let written = bytes_written.load(Ordering::Acquire);
(written >= STREAM_THRESHOLD).then_some(PlaybackSource::Streaming {
path,
bytes_written,
total,
})
}
_ => None,
})
}
pub fn reset_items_with_missing_files(&self) -> Vec<(i64, QueueItemId)> {
let mut pl = self.playlist.write();
let mut reset = Vec::new();
for item in pl.items.iter_mut() {
let Some(db_id) = item.db_id else { continue };
if !matches!(item.state, ItemState::Ready) {
continue;
}
if item.path.exists() {
continue;
}
item.state = ItemState::Pending;
reset.push((db_id, item.id));
}
drop(pl);
if !reset.is_empty() {
self.pending_version.fetch_add(1, Ordering::AcqRel);
self.bump_version();
}
reset
}
pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
let pl = self.playlist.read();
pl.items.iter().find(|item| item.id == id).and_then(|item| {
if matches!(item.state, ItemState::Ready) {
Some(item.path.clone())
} else {
None
}
})
}
pub fn is_cursor(&self, id: QueueItemId) -> bool {
self.playlist.read().cursor == Some(id)
}
pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
let pl = self.playlist.read();
let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
return vec![];
};
let album = cursor.album.clone();
let album_artist = cursor.album_artist.clone();
pl.items
.iter()
.filter(|item| {
item.id != id && item.album == album && item.album_artist == album_artist
})
.map(|item| item.id)
.collect()
}
pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
let pl = self.playlist.read();
reach_order(&pl)
.filter(|item| matches!(item.state, ItemState::Pending))
.filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
.collect()
}
pub fn playback_order(&self) -> Vec<(i64, QueueItemId)> {
let pl = self.playlist.read();
reach_order(&pl)
.filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
.collect()
}
pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
let pl = self.playlist.read();
pl.items
.iter()
.find(|item| item.id == id)
.and_then(|item| item.db_id)
}
pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
let pl = self.playlist.read();
pl.items
.iter()
.find(|item| item.id == id)
.map(|item| self.load_state(item))
}
pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
let pl = self.playlist.read();
(pl.items.clone(), pl.cursor)
}
pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
let pl = self.playlist.read();
pl.items.iter().find(|item| item.id == id).cloned()
}
pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
let pl = self.playlist.read();
let pos = pl.items.iter().position(|item| item.id == id)?;
if pos == 0 {
None
} else {
Some(pl.items[pos - 1].id)
}
}
pub fn reorder_to(&self, order: &[QueueItemId]) {
let mut pl = self.playlist.write();
let mut taken: Vec<Option<PlaylistItem>> = pl.items.drain(..).map(Some).collect();
let mut sorted = Vec::with_capacity(taken.len());
for id in order {
if let Some(slot) = taken
.iter_mut()
.find(|i| i.as_ref().is_some_and(|i| i.id == *id))
&& let Some(item) = slot.take()
{
sorted.push(item);
}
}
sorted.extend(taken.into_iter().flatten());
pl.items = sorted;
drop(pl);
self.bump_content();
}
pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
use std::collections::HashSet;
let wanted: HashSet<QueueItemId> = ids.iter().copied().collect();
let pl = self.playlist.read();
pl.items
.iter()
.enumerate()
.filter(|(_, item)| wanted.contains(&item.id))
.map(|(pos, item)| {
let before = if pos == 0 {
None
} else {
Some(pl.items[pos - 1].id)
};
(item.id, before)
})
.collect()
}
pub fn surviving_item_before(
&self,
id: QueueItemId,
removed: &[QueueItemId],
) -> Option<QueueItemId> {
use std::collections::HashSet;
let removed: HashSet<QueueItemId> = removed.iter().copied().collect();
let pl = self.playlist.read();
let pos = pl.items.iter().position(|item| item.id == id)?;
pl.items[..pos]
.iter()
.rev()
.find(|item| !removed.contains(&item.id))
.map(|item| item.id)
}
pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
let mut pl = self.playlist.write();
pl.items = items;
pl.cursor = cursor;
drop(pl);
self.bump_content();
}
pub fn remove_items(&self, ids: &[QueueItemId]) {
use std::collections::HashSet;
let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
let mut pl = self.playlist.write();
pl.items.retain(|item| !id_set.contains(&item.id));
if let Some(cursor) = pl.cursor
&& id_set.contains(&cursor)
{
pl.cursor = None;
}
drop(pl);
self.bump_content();
}
pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
let mut pl = self.playlist.write();
let insert_at = match after {
Some(after_id) => {
match pl.items.iter().position(|i| i.id == after_id) {
Some(pos) => pos + 1,
None => pl.items.len(), }
}
None => 0,
};
pl.items.insert(insert_at, item);
drop(pl);
self.bump_content();
}
pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
let mut pl = self.playlist.write();
let Some(from) = pl.items.iter().position(|item| item.id == id) else {
return;
};
let item = pl.items.remove(from);
let insert_at = match after {
Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
Some(pos) => pos + 1,
None => pl.items.len(),
},
None => 0,
};
pl.items.insert(insert_at, item);
drop(pl);
self.bump_content();
}
pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
for &(id, after) in entries {
self.move_item_to(id, after);
}
}
pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
let mut entries = Vec::with_capacity(self.playlist.read().items.len());
let mut finished_count = 0;
let mut has_playing = false;
let mut queue_count = 0;
self.each_visible(|item, place, reading| {
match place {
Place::Played => finished_count += 1,
Place::Cursor => has_playing = true,
Place::Unplayed => queue_count += 1,
}
entries.push(QueueEntry {
id: item.id,
db_id: item.db_id,
playlist_entry_id: item.playlist_entry_id,
path: item.path.clone(),
title: item.title.clone(),
artist: item.artist.clone(),
album_artist: item.album_artist.clone(),
album: item.album.clone(),
year: item.year.clone(),
codec: item.codec.clone(),
track_number: item.track_number,
disc: item.disc,
duration_ms: reading.duration_ms,
status: reading.status,
download_progress: reading.download_progress,
error: reading.error,
});
});
VisibleQueueSnapshot {
entries,
finished_count,
has_playing,
queue_count,
}
}
pub fn queue_readings(&self) -> Vec<QueueReading> {
let mut readings = Vec::with_capacity(self.playlist.read().items.len());
self.each_visible(|_, _, reading| readings.push(reading));
readings
}
fn each_visible(&self, mut f: impl FnMut(&PlaylistItem, Place, QueueReading)) {
let playing_duration_ms = self.track_info.read().as_ref().map(|ti| ti.duration_ms);
let transfers: HashMap<i64, (u64, u64)> = self
.downloads
.readings()
.into_iter()
.map(|r| (r.track_id, (r.written, r.total)))
.collect();
let pl = self.playlist.read();
for item in &pl.items {
let place = if pl.cursor == Some(item.id) {
Place::Cursor
} else if item.played {
Place::Played
} else {
Place::Unplayed
};
let download_progress = match item.state {
ItemState::Pending => item.db_id.and_then(|id| transfers.get(&id).copied()),
_ => None,
};
let transferring = download_progress.is_some();
let status = match (place, &item.state) {
(Place::Cursor, state) => QueueEntryStatus::at_cursor(state, transferring),
(_, ItemState::Failed(_)) => QueueEntryStatus::Failed,
(_, ItemState::Pending) if transferring => QueueEntryStatus::Downloading,
(Place::Played, _) => QueueEntryStatus::Played,
(Place::Unplayed, _) => QueueEntryStatus::Queued,
};
let duration_ms = if status == QueueEntryStatus::Playing && item.duration_ms.is_none() {
playing_duration_ms
} else {
item.duration_ms
};
f(
item,
place,
QueueReading {
id: item.id,
db_id: item.db_id,
status,
duration_ms,
download_progress,
error: match &item.state {
ItemState::Failed(reason) => Some(reason.clone()),
_ => None,
},
},
);
}
}
pub fn playlist_window(
&self,
before: usize,
max: usize,
) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
let pl = self.playlist.read();
let at = pl
.cursor
.and_then(|c| pl.items.iter().position(|i| i.id == c))
.unwrap_or(0);
let start = at.saturating_sub(before);
let end = pl.items.len().min(start + max);
(pl.items[start..end].to_vec(), pl.cursor)
}
}
fn reach_order(pl: &Playlist) -> impl Iterator<Item = &PlaylistItem> {
let from = pl
.cursor
.and_then(|c| pl.items.iter().position(|item| item.id == c))
.unwrap_or(0);
let (before, after) = pl.items.split_at(from);
let queue = after.iter().chain(before);
let mut first: Vec<&PlaylistItem> = Vec::new();
if let Some(order) = &pl.order {
let rows: HashMap<QueueItemId, &PlaylistItem> =
pl.items.iter().map(|item| (item.id, item)).collect();
first.extend(pl.cursor.and_then(|c| rows.get(&c).copied()));
first.extend(order.upcoming.iter().filter_map(|id| rows.get(id).copied()));
}
let led: std::collections::HashSet<QueueItemId> = first.iter().map(|item| item.id).collect();
first
.into_iter()
.chain(queue.filter(move |item| !led.contains(&item.id)))
}
#[derive(Clone, Copy)]
enum Place {
Played,
Cursor,
Unplayed,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_item(title: &str, state: ItemState) -> PlaylistItem {
PlaylistItem {
playlist_entry_id: None,
id: QueueItemId::new(),
db_id: None,
path: PathBuf::from(format!("/music/{title}.flac")),
title: title.to_string(),
artist: "Artist".to_string(),
album_artist: "Artist".to_string(),
album: "Album".to_string(),
year: None,
codec: Some("FLAC".to_string()),
track_number: None,
disc: None,
duration_ms: Some(200_000),
state,
played: false,
}
}
fn tracks(n: usize) -> Vec<PlaylistItem> {
(0..n)
.map(|i| PlaylistItem {
db_id: Some(i as i64 + 1),
..make_item(&format!("t{i}"), ItemState::Ready)
})
.collect()
}
fn shuffled(items: Vec<PlaylistItem>) -> (Arc<SharedPlayerState>, Vec<QueueItemId>) {
let state = SharedPlayerState::new();
let ids = items.iter().map(|i| i.id).collect();
state.add_items(items);
state.set_shuffled(true);
(state, ids)
}
fn queue_ids(state: &SharedPlayerState) -> Vec<QueueItemId> {
state.snapshot_playlist().0.iter().map(|i| i.id).collect()
}
fn play_on(state: &SharedPlayerState) -> Option<QueueItemId> {
if let Some(id) = state.cursor() {
state.mark_played(id);
}
state.advance_cursor_loadable()
}
fn status_of(state: &SharedPlayerState, id: QueueItemId) -> QueueEntryStatus {
let snap = state.derive_visible_queue();
snap.entries.iter().find(|e| e.id == id).unwrap().status
}
fn set_repeat(state: &SharedPlayerState, repeat: Repeat) {
let shuffle = state.play_mode().shuffle;
state.set_play_mode(PlayMode { shuffle, repeat });
}
#[test]
fn shuffle_on_and_off_never_moves_the_queue() {
let state = SharedPlayerState::new();
let items = tracks(20);
let ids: Vec<_> = items.iter().map(|i| i.id).collect();
state.add_items(items);
state.set_cursor(Some(ids[3]));
state.set_shuffled(true);
assert_eq!(queue_ids(&state), ids);
for _ in 0..6 {
play_on(&state).unwrap();
}
assert_eq!(queue_ids(&state), ids, "playing shuffled moves nothing");
state.set_shuffled(false);
assert_eq!(queue_ids(&state), ids);
}
#[test]
fn the_rows_that_played_shuffled_stay_marked_once_it_is_off() {
let (state, ids) = shuffled(tracks(20));
let mut heard = vec![state.advance_cursor_loadable().unwrap()];
for _ in 0..7 {
heard.push(play_on(&state).unwrap());
}
let playing = state.cursor().unwrap();
state.set_shuffled(false);
for &id in &ids {
let expected = if id == playing {
QueueEntryStatus::Playing
} else if heard.contains(&id) {
QueueEntryStatus::Played
} else {
QueueEntryStatus::Queued
};
assert_eq!(
status_of(&state, id),
expected,
"row {}",
ids.iter().position(|i| *i == id).unwrap()
);
}
let snap = state.derive_visible_queue();
assert_eq!(snap.finished_count, 7);
assert_eq!(snap.queue_count, 12);
let at = ids.iter().position(|id| *id == playing).unwrap();
assert_eq!(play_on(&state), ids.get(at + 1).copied());
}
#[test]
fn a_row_is_played_because_it_played_not_for_being_behind_the_cursor() {
let state = SharedPlayerState::new();
let items = tracks(6);
let ids: Vec<_> = items.iter().map(|i| i.id).collect();
state.add_items(items);
state.set_cursor(Some(ids[0]));
play_on(&state);
state.set_cursor(Some(ids[4]));
assert_eq!(status_of(&state, ids[0]), QueueEntryStatus::Played);
for &id in &ids[1..4] {
assert_eq!(
status_of(&state, id),
QueueEntryStatus::Queued,
"jumped over"
);
}
}
#[test]
fn a_shuffled_pass_plays_every_track_once() {
let (state, ids) = shuffled(tracks(30));
let mut heard = vec![state.advance_cursor_loadable().unwrap()];
while let Some(id) = play_on(&state) {
heard.push(id);
}
assert_ne!(heard, ids, "a shuffled order");
let mut sorted = heard.clone();
sorted.sort_by_key(|id| ids.iter().position(|i| i == id));
assert_eq!(sorted, ids, "each once");
}
#[test]
fn a_track_queued_twice_plays_once_a_pass() {
let mut items = tracks(5);
let twins: Vec<_> = [1, 3, 3]
.iter()
.map(|&i| PlaylistItem {
id: QueueItemId::new(),
..items[i].clone()
})
.collect();
items.extend(twins);
let (state, _) = shuffled(items);
let track = |id| state.item_db_id(id).unwrap();
let mut heard = vec![track(state.advance_cursor_loadable().unwrap())];
while let Some(id) = play_on(&state) {
heard.push(track(id));
}
heard.sort();
assert_eq!(heard, vec![1, 2, 3, 4, 5]);
}
#[test]
fn rows_queued_while_shuffled_play_later_in_the_pass() {
let (state, ids) = shuffled(tracks(10));
let mut heard = vec![state.advance_cursor_loadable().unwrap()];
for _ in 0..3 {
heard.push(play_on(&state).unwrap());
}
let more: Vec<_> = (10..15)
.map(|i| PlaylistItem {
db_id: Some(i + 1),
..make_item(&format!("t{i}"), ItemState::Ready)
})
.collect();
let added: Vec<_> = more.iter().map(|i| i.id).collect();
state.add_items(more);
assert_eq!(queue_ids(&state)[10..], added, "added where they were put");
while let Some(id) = play_on(&state) {
heard.push(id);
}
assert_eq!(heard.len(), 15);
assert!(added.iter().all(|id| heard[4..].contains(id)));
assert!(ids.iter().all(|id| heard.contains(id)));
}
#[test]
fn a_row_removed_while_shuffled_does_not_play() {
let (state, ids) = shuffled(tracks(10));
let mut heard = vec![state.advance_cursor_loadable().unwrap()];
let gone = *ids.iter().find(|id| !heard.contains(id)).unwrap();
state.remove_items(&[gone]);
while let Some(id) = play_on(&state) {
heard.push(id);
}
assert_eq!(heard.len(), 9);
assert!(!heard.contains(&gone));
}
#[test]
fn previous_goes_back_along_what_played_while_shuffled() {
let (state, ids) = shuffled(tracks(12));
let mut heard = vec![state.advance_cursor_loadable().unwrap()];
for _ in 0..4 {
heard.push(play_on(&state).unwrap());
}
assert_eq!(state.retreat_cursor().map(|(id, _)| id), Some(heard[3]));
assert_eq!(state.retreat_cursor().map(|(id, _)| id), Some(heard[2]));
assert_eq!(state.retreat_cursor().map(|(id, _)| id), Some(heard[1]));
assert_eq!(state.retreat_cursor().map(|(id, _)| id), Some(heard[0]));
assert_eq!(state.retreat_cursor(), None, "nothing before the first");
assert_eq!(state.cursor(), Some(heard[0]));
let next = play_on(&state).unwrap();
assert!(!heard.contains(&next));
assert_eq!(queue_ids(&state), ids);
}
#[test]
fn a_repeating_shuffled_queue_starts_a_new_pass_once_every_track_played() {
let (state, ids) = shuffled(tracks(8));
set_repeat(&state, Repeat::Queue);
let mut heard = vec![state.advance_cursor_loadable().unwrap()];
for _ in 0..(3 * ids.len() - 1) {
heard.push(play_on(&state).unwrap());
}
for (n, pass) in heard.chunks(ids.len()).enumerate() {
let mut sorted = pass.to_vec();
sorted.sort_by_key(|id| ids.iter().position(|i| i == id));
assert_eq!(sorted, ids, "pass {n} plays each track once");
}
for turn in heard.windows(2) {
assert_ne!(turn[0], turn[1], "no track twice running at a turn");
}
let snap = state.derive_visible_queue();
assert_eq!(snap.finished_count, ids.len() - 1);
play_on(&state);
let snap = state.derive_visible_queue();
assert_eq!(snap.finished_count, 0, "a new pass clears the marks");
assert!(snap.has_playing);
}
#[test]
fn repeating_in_order_starts_a_new_pass_at_the_top() {
let state = SharedPlayerState::new();
let items = tracks(4);
let ids: Vec<_> = items.iter().map(|i| i.id).collect();
state.add_items(items);
set_repeat(&state, Repeat::Queue);
state.set_cursor(Some(ids[0]));
for _ in 0..4 {
play_on(&state);
}
assert_eq!(state.cursor(), Some(ids[0]));
for &id in &ids[1..] {
assert_eq!(status_of(&state, id), QueueEntryStatus::Queued);
}
}
#[test]
fn the_lookahead_picks_what_the_advance_plays() {
let (state, ids) = shuffled(tracks(7));
set_repeat(&state, Repeat::Queue);
state.advance_cursor_loadable();
for _ in 0..(3 * ids.len()) {
let cursor = state.cursor().unwrap();
let next = state.lookahead_after(cursor).unwrap();
let after = state.lookahead_from(&next).unwrap();
assert!(state.still_follows(&next));
assert_eq!(play_on(&state), next.next);
assert_eq!(play_on(&state), after.next);
assert_eq!(next.chosen.map(|(id, _)| id), next.next);
}
}
#[test]
fn a_lookahead_steps_deep_crosses_into_the_next_pass_as_the_advance_does() {
let (state, ids) = shuffled(tracks(4));
set_repeat(&state, Repeat::Queue);
state.advance_cursor_loadable();
let mut step = state.lookahead_after(state.cursor().unwrap()).unwrap();
let mut chain = vec![step.next.unwrap()];
for _ in 1..(ids.len() - 1 + ids.len()) {
step = state.lookahead_from(&step).unwrap();
chain.push(step.next.unwrap());
}
let played: Vec<_> = chain.iter().map(|_| play_on(&state).unwrap()).collect();
assert_eq!(played, chain);
let mut next_pass = chain[ids.len() - 1..].to_vec();
next_pass.sort_by_key(|id| ids.iter().position(|i| i == id));
assert_eq!(next_pass, ids, "the next pass whole, not this one again");
}
#[test]
fn a_played_out_shuffled_queue_plays_again_from_a_row_picked() {
let (state, ids) = shuffled(tracks(5));
state.advance_cursor_loadable();
while play_on(&state).is_some() {}
state.pick(ids[2]);
let mut heard = vec![ids[2]];
while let Some(id) = play_on(&state) {
heard.push(id);
}
heard.sort_by_key(|id| ids.iter().position(|i| i == id));
assert_eq!(heard, ids, "a new pass from the row picked");
}
#[test]
fn shuffling_a_played_out_queue_plays_it_again() {
let state = SharedPlayerState::new();
let items = tracks(5);
let ids: Vec<_> = items.iter().map(|i| i.id).collect();
state.add_items(items);
state.set_cursor(Some(ids[0]));
while play_on(&state).is_some() {}
state.set_shuffled(true);
let mut heard = vec![state.cursor().unwrap()];
while let Some(id) = play_on(&state) {
heard.push(id);
}
assert_eq!(heard.len(), ids.len());
}
#[test]
fn the_downloads_follow_the_play_order() {
let state = SharedPlayerState::new();
let items: Vec<_> = tracks(10)
.into_iter()
.map(|item| PlaylistItem {
state: ItemState::Pending,
..item
})
.collect();
state.add_items(items);
state.set_shuffled(true);
let first = state.advance_cursor_loadable().unwrap();
let second = state.lookahead_after(first).unwrap().next.unwrap();
let order = state.pending_downloads();
assert_eq!(order[0].1, first);
assert_eq!(order[1].1, second);
assert_eq!(order.len(), 10);
}
fn downloading_item(
state: &SharedPlayerState,
title: &str,
total: u64,
) -> (PlaylistItem, Arc<ByteFeed>) {
static NEXT_TRACK: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(1);
let mut item = make_item(title, ItemState::Pending);
item.db_id = Some(NEXT_TRACK.fetch_add(1, Ordering::Relaxed));
let feed = start_transfer(state, &item, total);
(item, feed)
}
fn start_transfer(state: &SharedPlayerState, item: &PlaylistItem, total: u64) -> Arc<ByteFeed> {
let track_id = item.db_id.expect("a transfer is for a library track");
let store = state.downloads();
store.claim(track_id, Some(item.id));
let feed = store.announce(
track_id,
item.title.clone(),
String::new(),
PathBuf::from(format!("/cache/{}.flac.part", item.title)),
PathBuf::from(format!("/cache/{}.flac", item.title)),
);
store.started(track_id, total);
feed
}
fn ready_item(title: &str) -> PlaylistItem {
make_item(title, ItemState::Ready)
}
fn pending_item(title: &str) -> PlaylistItem {
make_item(title, ItemState::Pending)
}
fn failed_item(title: &str) -> PlaylistItem {
make_item(title, ItemState::Failed("nope".into()))
}
const DURATION_MS: u64 = 32_523_787;
fn streaming_state(
downloaded: u64,
total: u64,
bitrate_kbps: Option<u32>,
) -> Arc<SharedPlayerState> {
streaming_state_with_duration(downloaded, total, bitrate_kbps, DURATION_MS)
}
fn streaming_state_with_duration(
downloaded: u64,
total: u64,
bitrate_kbps: Option<u32>,
container_duration_ms: u64,
) -> Arc<SharedPlayerState> {
let mut item = make_item("train", ItemState::Pending);
item.db_id = Some(1);
let id = item.id;
let path = item.path.clone();
let state = SharedPlayerState::new();
start_transfer(&state, &item, total).set(downloaded);
state.add_items(vec![item]);
state.set_cursor(Some(id));
state.set_track_info(Some(TrackInfo {
id,
path,
codec: "Opus".into(),
sample_rate: 48_000,
bit_depth: None,
bitrate_kbps,
channels: 2,
duration_ms: container_duration_ms,
}));
state
}
#[test]
fn a_track_on_disk_is_seekable_end_to_end() {
let item = ready_item("done");
let id = item.id;
let path = item.path.clone();
let state = SharedPlayerState::new();
state.add_items(vec![item]);
state.set_cursor(Some(id));
state.set_track_info(Some(TrackInfo {
id,
path,
codec: "FLAC".into(),
sample_rate: 44_100,
bit_depth: Some(16),
bitrate_kbps: None,
channels: 2,
duration_ms: 200_000,
}));
assert_eq!(state.seekable_ms(), 200_000);
assert_eq!(state.seek_ceiling_ms(), None);
}
#[test]
fn a_downloading_track_is_seekable_as_far_as_its_bytes_reach() {
let state = streaming_state(100, 400, None);
assert_eq!(state.seekable_ms(), 32_523_787 / 4 - SEEK_SAFETY_MS);
assert_eq!(
state.seek_ceiling_ms(),
Some(32_523_787 / 4 - SEEK_SAFETY_MS)
);
}
#[test]
fn a_transfer_without_a_content_length_falls_back_to_bitrate() {
let state = streaming_state(1024 * 1024, 0, Some(128));
assert_eq!(state.seekable_ms(), 1024 * 1024 * 8 / 128 - SEEK_SAFETY_MS);
}
#[test]
fn nothing_to_estimate_from_allows_no_forward_seek() {
let state = streaming_state(1024 * 1024, 0, None);
state.set_position_ms(12_000);
assert_eq!(state.seekable_ms(), 12_000);
}
#[test]
fn the_seekable_extent_never_exceeds_the_track() {
let state = streaming_state(500, 400, None);
assert_eq!(state.seekable_ms(), 32_523_787);
}
#[test]
fn nothing_playing_is_seekable_nowhere() {
assert_eq!(SharedPlayerState::new().seekable_ms(), 0);
assert_eq!(SharedPlayerState::new().seek_ceiling_ms(), None);
}
#[test]
fn a_container_that_cannot_state_its_duration_cannot_be_seeked() {
let state = streaming_state_with_duration(200, 400, Some(128), 0);
assert_eq!(state.seekable_ms(), 0);
}
#[test]
fn the_library_duration_stands_in_for_a_silent_container() {
let state = streaming_state_with_duration(200, 400, Some(128), 0);
assert_eq!(state.duration_ms(), 200_000, "the item's own figure");
assert_eq!(state.seekable_ms(), 0);
assert_eq!(state.seek_ceiling_ms(), Some(0));
}
#[test]
fn the_container_duration_wins_where_there_is_one() {
let state = streaming_state(200, 400, None);
assert_eq!(state.duration_ms(), DURATION_MS);
}
#[test]
fn the_download_landing_restores_seeking() {
let state = streaming_state_with_duration(400, 400, Some(128), 0);
assert_eq!(state.seekable_ms(), 0);
let id = state.cursor().expect("cursor");
let _ =
crate::remote::downloads::settle(&state, 1, &Ok(PathBuf::from("/cache/train.flac")));
assert_eq!(state.item_state(id), Some(ItemState::Ready));
let info = state.track_info().expect("track info");
state.set_track_info(Some(TrackInfo {
duration_ms: DURATION_MS,
..info
}));
assert_eq!(state.seekable_ms(), DURATION_MS);
assert_eq!(state.seek_ceiling_ms(), None, "no boundary left to draw");
}
#[test]
fn advance_parks_on_a_still_downloading_track() {
let state = SharedPlayerState::new();
let item0 = ready_item("track-0");
let item1 = pending_item("track-1");
let item2 = ready_item("track-2");
let (id0, id1) = (item0.id, item1.id);
state.add_items(vec![item0, item1, item2]);
assert_eq!(state.advance_cursor_loadable(), Some(id0));
assert_eq!(state.advance_cursor_loadable(), Some(id1));
assert_eq!(state.cursor(), Some(id1));
}
#[test]
fn advance_skips_failed_items() {
let state = SharedPlayerState::new();
let item0 = ready_item("track-0");
let item1 = failed_item("track-1");
let item2 = ready_item("track-2");
let (id0, id2) = (item0.id, item2.id);
state.add_items(vec![item0, item1, item2]);
state.set_cursor(Some(id0));
assert_eq!(state.advance_cursor_loadable(), Some(id2));
}
#[test]
fn advance_stops_at_end_of_playlist() {
let state = SharedPlayerState::new();
let item0 = ready_item("track-0");
let item1 = ready_item("track-1");
let id1 = item1.id;
state.add_items(vec![item0, item1]);
state.set_cursor(Some(id1));
assert_eq!(state.advance_cursor_loadable(), None);
assert_eq!(
state.cursor(),
Some(id1),
"cursor unchanged on a failed advance"
);
}
#[test]
fn advance_with_only_failed_items_returns_none() {
let state = SharedPlayerState::new();
state.add_items(vec![failed_item("bad-0"), failed_item("bad-1")]);
assert_eq!(state.advance_cursor_loadable(), None);
}
#[test]
fn advance_from_a_vanished_cursor_does_not_restart_the_queue() {
let state = SharedPlayerState::new();
let item0 = ready_item("track-0");
let item1 = ready_item("track-1");
let id0 = item0.id;
state.add_items(vec![item0, item1]);
let ghost = QueueItemId::new();
state.set_cursor(Some(ghost));
assert_eq!(state.advance_cursor_loadable(), None);
assert_ne!(state.cursor(), Some(id0));
}
fn chosen_after(state: &SharedPlayerState, id: QueueItemId) -> Option<QueueItemId> {
state
.lookahead_after(id)
.and_then(|step| step.chosen)
.map(|(id, _)| id)
}
#[test]
fn a_step_stops_at_a_track_still_arriving() {
let state = SharedPlayerState::new();
let a = ready_item("a");
let b = PlaylistItem {
state: ItemState::Pending,
..ready_item("b")
};
let c = ready_item("c");
let (ida, idb) = (a.id, b.id);
state.add_items(vec![a, b, c]);
let step = state.lookahead_after(ida).unwrap();
assert_eq!(step.next, Some(idb));
assert!(step.chosen.is_none(), "c is not queued over it");
state.update_item_state(idb, ItemState::Ready);
assert!(
state.still_follows(&step),
"its landing is not an edit: the advance plays it"
);
state.add_items(vec![ready_item("d")]);
assert!(state.still_follows(&step), "nor is adding after it");
state.insert_items_after(vec![ready_item("next")], ida);
assert!(!state.still_follows(&step), "a track put before it is");
}
#[test]
fn a_step_passes_over_failed_tracks() {
let state = SharedPlayerState::new();
let a = ready_item("a");
let failed = PlaylistItem {
state: ItemState::Failed("gone".into()),
..ready_item("failed")
};
let c = ready_item("c");
let (ida, idc) = (a.id, c.id);
state.add_items(vec![a, failed, c]);
let step = state.lookahead_after(ida).unwrap();
assert_eq!(step.chosen.as_ref().map(|(id, _)| *id), Some(idc));
let another = PlaylistItem {
state: ItemState::Failed("gone".into()),
..ready_item("another")
};
state.insert_items_after(vec![another], ida);
assert!(
state.still_follows(&step),
"another failed track before it changes nothing"
);
let arriving = PlaylistItem {
state: ItemState::Pending,
..ready_item("arriving")
};
state.insert_items_after(vec![arriving], ida);
assert!(!state.still_follows(&step), "a track still arriving does");
}
#[test]
fn a_step_breaks_when_what_it_chose_moves_ahead_of_it() {
let state = SharedPlayerState::new();
let (a, c) = (ready_item("a"), ready_item("c"));
let (ida, idc) = (a.id, c.id);
state.add_items(vec![a, c]);
let step = state.lookahead_after(ida).unwrap();
state.move_item(idc, ida, false);
assert!(!state.still_follows(&step));
}
#[test]
fn a_step_to_the_end_of_the_queue_breaks_when_a_track_is_added() {
let state = SharedPlayerState::new();
let a = ready_item("a");
let ida = a.id;
state.add_items(vec![a]);
let step = state.lookahead_after(ida).unwrap();
assert!(step.chosen.is_none());
assert!(state.still_follows(&step));
state.add_items(vec![ready_item("b")]);
assert!(!state.still_follows(&step));
}
#[test]
fn peek_after_a_removed_item_returns_none() {
let state = SharedPlayerState::new();
let item0 = ready_item("track-0");
let item1 = ready_item("track-1");
let item2 = ready_item("track-2");
let (id0, id1, id2) = (item0.id, item1.id, item2.id);
state.add_items(vec![item0, item1, item2]);
assert_eq!(chosen_after(&state, id1), Some(id2));
state.remove_item(id2);
assert!(
state.lookahead_after(id2).is_none(),
"a vanished reference must not resolve to the head of the queue"
);
assert_ne!(chosen_after(&state, id2), Some(id0));
}
#[test]
fn the_lookahead_steps_over_a_track_that_failed() {
let state = SharedPlayerState::new();
let playing = ready_item("playing");
let failed = failed_item("failed");
let after = ready_item("after");
let (playing_id, after_id) = (playing.id, after.id);
state.add_items(vec![playing, failed, after]);
assert_eq!(chosen_after(&state, playing_id), Some(after_id));
}
fn repeating(state: &SharedPlayerState, repeat: Repeat) {
state.set_play_mode(PlayMode {
shuffle: false,
repeat,
});
}
#[test]
fn a_step_from_the_last_item_wraps_only_when_the_queue_repeats() {
let state = SharedPlayerState::new();
let items: Vec<_> = ["a", "b"].map(ready_item).into();
let (a, b) = (items[0].id, items[1].id);
state.add_items(items);
assert_eq!(state.lookahead_after(b).unwrap().next, None);
repeating(&state, Repeat::Queue);
let step = state.lookahead_after(b).unwrap();
assert_eq!((step.next, step.wrapped), (Some(a), true));
assert!(state.still_follows(&step));
let later = ready_item("later");
state.add_items(vec![later.clone()]);
assert!(!state.still_follows(&step), "something follows b now");
state.remove_items(&[later.id]);
repeating(&state, Repeat::Off);
assert!(!state.still_follows(&step), "nor does repeat off wrap");
}
#[test]
fn repeating_one_steps_to_the_same_item_but_an_advance_moves_on() {
let state = SharedPlayerState::new();
let items: Vec<_> = ["a", "b"].map(ready_item).into();
let (a, b) = (items[0].id, items[1].id);
state.add_items(items);
repeating(&state, Repeat::One);
let step = state.lookahead_after(a).unwrap();
assert_eq!((step.next, step.wrapped), (Some(a), false));
state.set_cursor(Some(a));
assert_eq!(state.advance_cursor_loadable(), Some(b));
assert_eq!(
state.advance_cursor_loadable(),
Some(a),
"round, as repeating"
);
}
#[test]
fn previous_from_the_first_item_wraps_only_while_repeating() {
let state = SharedPlayerState::new();
let items: Vec<_> = ["a", "b", "c"].map(ready_item).into();
let (a, c) = (items[0].id, items[2].id);
state.add_items(items);
state.set_cursor(Some(a));
assert!(state.retreat_cursor().is_none());
for repeat in [Repeat::Queue, Repeat::One] {
repeating(&state, repeat);
state.set_cursor(Some(a));
assert_eq!(
state.retreat_cursor().map(|(id, _)| id),
Some(c),
"{repeat:?}"
);
}
}
#[test]
fn a_removed_item_has_nothing_after_it_even_when_the_queue_repeats() {
let state = SharedPlayerState::new();
let items: Vec<_> = ["a", "b"].map(ready_item).into();
let b = items[1].id;
state.add_items(items);
repeating(&state, Repeat::Queue);
state.set_cursor(Some(b));
state.remove_item(b);
assert!(state.lookahead_after(b).is_none());
state.set_cursor(Some(b));
assert_eq!(state.advance_cursor_loadable(), None);
}
#[test]
fn a_wrap_passes_over_failed_items() {
let state = SharedPlayerState::new();
let items = vec![failed_item("a"), ready_item("b"), ready_item("c")];
let (b, c) = (items[1].id, items[2].id);
state.add_items(items);
repeating(&state, Repeat::Queue);
assert_eq!(state.lookahead_after(c).unwrap().next, Some(b));
}
#[test]
fn surviving_predecessor_skips_items_being_removed() {
let state = SharedPlayerState::new();
let items: Vec<_> = (0..4).map(|i| ready_item(&format!("track-{i}"))).collect();
let ids: Vec<_> = items.iter().map(|i| i.id).collect();
state.add_items(items);
assert_eq!(
state.surviving_item_before(ids[3], &ids[1..4]),
Some(ids[0])
);
assert_eq!(state.surviving_item_before(ids[2], &ids), None);
}
#[test]
fn test_retreat_cursor_goes_to_previous_item() {
let state = SharedPlayerState::new();
let item0 = ready_item("track-0");
let item1 = ready_item("track-1");
let id0 = item0.id;
let id1 = item1.id;
state.add_items(vec![item0, item1]);
state.set_cursor(Some(id1));
let result = state.retreat_cursor();
assert!(result.is_some(), "expected to retreat to previous item");
assert_eq!(result.unwrap().0, id0, "should retreat to first item");
assert_eq!(state.cursor(), Some(id0));
}
#[test]
fn test_retreat_cursor_returns_none_when_at_first_item() {
let state = SharedPlayerState::new();
let item0 = ready_item("only-track");
let id0 = item0.id;
state.add_items(vec![item0]);
state.set_cursor(Some(id0));
let result = state.retreat_cursor();
assert!(result.is_none(), "cannot retreat before the first item");
assert_eq!(state.cursor(), Some(id0));
}
#[test]
fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
let state = SharedPlayerState::new();
state.add_items(vec![ready_item("track-0")]);
let result = state.retreat_cursor();
assert!(
result.is_none(),
"retreat with no cursor should return None"
);
}
#[test]
fn test_derive_visible_queue_statuses() {
let state = SharedPlayerState::new();
let item0 = ready_item("played-track");
let item1 = ready_item("playing-track");
let item2 = ready_item("queued-track");
let (id0, id1) = (item0.id, item1.id);
state.add_items(vec![item0, item1, item2]);
state.set_cursor(Some(id0));
state.mark_played(id0);
state.set_cursor(Some(id1));
let snap = state.derive_visible_queue();
assert_eq!(snap.entries.len(), 3);
assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
assert!(snap.has_playing);
assert_eq!(snap.finished_count, 1);
assert_eq!(snap.queue_count, 1);
}
#[test]
fn test_derive_visible_queue_downloading_statuses() {
let state = SharedPlayerState::new();
let (dl_cursor, _) = downloading_item(&state, "downloading-at-cursor", 1_000_000);
let (dl_queued, _) = downloading_item(&state, "downloading-queued", 500_000);
let id_cursor = dl_cursor.id;
state.add_items(vec![dl_cursor, dl_queued]);
state.set_cursor(Some(id_cursor));
let snap = state.derive_visible_queue();
assert_eq!(snap.entries[0].status, QueueEntryStatus::Downloading);
assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
}
#[test]
fn a_cursor_waiting_its_turn_is_priority_pending() {
let state = SharedPlayerState::new();
let item = pending_item("waiting");
let id = item.id;
state.add_items(vec![item]);
state.set_cursor(Some(id));
let snap = state.derive_visible_queue();
assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
}
#[test]
fn a_second_entry_for_a_track_reads_the_transfer_running_for_it() {
let state = SharedPlayerState::new();
let (first, bytes) = downloading_item(&state, "twice", 1_000_000);
bytes.set(STREAM_THRESHOLD);
let mut again = pending_item("twice");
again.db_id = first.db_id;
let again_id = again.id;
state.add_items(vec![first, again]);
assert!(matches!(
state.item_load_state(again_id),
Some(LoadState::Downloading { .. })
));
assert!(matches!(
state.item_playback_source(again_id),
Some(PlaybackSource::Streaming { .. })
));
}
#[test]
fn only_a_change_to_what_is_saved_moves_the_content_version() {
let state = SharedPlayerState::new();
let a = make_item("a", ItemState::Pending);
let b = make_item("b", ItemState::Ready);
let (a_id, b_id) = (a.id, b.id);
let start = state.content_version();
state.add_items(vec![a, b]);
let added = state.content_version();
assert_ne!(added, start);
state.set_cursor(Some(a_id));
state.update_item_state(a_id, ItemState::Ready);
state.advance_cursor_loadable();
state.retreat_cursor();
assert_eq!(state.content_version(), added);
assert_eq!(state.cursor_path(), Some(PathBuf::from("/music/a.flac")));
state.move_item_to(b_id, None);
assert_ne!(state.content_version(), added);
}
#[test]
fn progress_follows_the_counter_without_touching_the_playlist() {
let state = SharedPlayerState::new();
let (item, bytes) = downloading_item(&state, "downloading", 1_000);
state.add_items(vec![item]);
let version = state.playlist_version();
bytes.set(250);
let snap = state.derive_visible_queue();
assert_eq!(snap.entries[0].download_progress, Some((250, 1_000)));
assert_eq!(
state.playlist_version(),
version,
"progress must not read as a queue mutation"
);
assert_eq!(state.downloads().readings()[0].written, 250);
}
#[test]
fn test_derive_visible_queue_no_cursor_all_queued() {
let state = SharedPlayerState::new();
state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
let snap = state.derive_visible_queue();
assert_eq!(snap.entries.len(), 3);
for entry in &snap.entries {
assert_eq!(entry.status, QueueEntryStatus::Queued);
}
assert!(!snap.has_playing);
assert_eq!(snap.finished_count, 0);
assert_eq!(snap.queue_count, 3);
}
fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
PlaylistItem {
playlist_entry_id: None,
id: QueueItemId::new(),
db_id: None,
path: PathBuf::from(format!("/music/{title}.flac")),
title: title.to_string(),
artist: "Artist".to_string(),
album_artist: album_artist.to_string(),
album: album.to_string(),
year: None,
codec: Some("FLAC".to_string()),
track_number: None,
disc: None,
duration_ms: Some(200_000),
state: ItemState::Ready,
played: false,
}
}
#[test]
fn test_same_album_item_ids_returns_album_mates() {
let state = SharedPlayerState::new();
let a1 = make_album_item("A1", "Album A", "Artist A");
let a2 = make_album_item("A2", "Album A", "Artist A");
let b1 = make_album_item("B1", "Album B", "Artist B");
let a3 = make_album_item("A3", "Album A", "Artist A");
let id_a1 = a1.id;
let id_a2 = a2.id;
let id_a3 = a3.id;
state.add_items(vec![a1, a2, b1, a3]);
let mates = state.same_album_item_ids(id_a1);
assert_eq!(mates.len(), 2);
assert!(mates.contains(&id_a2));
assert!(mates.contains(&id_a3));
}
#[test]
fn test_same_album_item_ids_distinguishes_album_artists() {
let state = SharedPlayerState::new();
let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
let id_a1 = a1.id;
state.add_items(vec![a1, b1]);
let mates = state.same_album_item_ids(id_a1);
assert!(mates.is_empty(), "different album_artist should not match");
}
#[test]
fn test_same_album_item_ids_unknown_id_returns_empty() {
let state = SharedPlayerState::new();
state.add_items(vec![ready_item("track-0")]);
let bogus = QueueItemId::new();
let mates = state.same_album_item_ids(bogus);
assert!(mates.is_empty());
}
#[test]
fn test_update_item_metadata_leaves_library_tags_alone() {
let state = SharedPlayerState::new();
let mut item = make_album_item("A1", "Nite Versions (mixed)", "Soulwax");
item.db_id = Some(29615);
let id = item.id;
state.add_items(vec![item]);
state.update_item_metadata(
id,
"[unknown]".into(),
"Soulwax".into(),
"Soulwax".into(),
"Nite Versions".into(),
Some(54_000),
);
let pl = state.playlist.read();
assert_eq!(pl.items[0].album, "Nite Versions (mixed)");
assert_eq!(pl.items[0].title, "A1");
assert_eq!(pl.items[0].duration_ms, Some(54_000));
}
#[test]
fn test_update_item_metadata_fills_in_an_item_with_nothing_behind_it() {
let state = SharedPlayerState::new();
let item = make_album_item("A1", "", "");
let id = item.id;
state.add_items(vec![item]);
state.update_item_metadata(
id,
"Teachers".into(),
"Soulwax".into(),
"Soulwax".into(),
"Nite Versions".into(),
Some(148_000),
);
let pl = state.playlist.read();
assert_eq!(pl.items[0].title, "Teachers");
assert_eq!(pl.items[0].album, "Nite Versions");
assert_eq!(pl.items[0].duration_ms, Some(148_000));
}
#[test]
fn test_update_item_metadata_is_an_edit_only_for_new_tags() {
let state = SharedPlayerState::new();
let mut item = make_album_item("A1", "Nite Versions", "Soulwax");
item.db_id = Some(1);
let id = item.id;
state.add_items(vec![item]);
let content = state.content_version();
let version = state.playlist_version();
let land = |duration_ms| {
state.update_item_metadata(
id,
"A1".into(),
"Soulwax".into(),
"Soulwax".into(),
"Nite Versions".into(),
Some(duration_ms),
)
};
land(200_000);
assert_eq!(state.content_version(), content);
assert_eq!(state.playlist_version(), version);
land(200_417);
assert_eq!(state.content_version(), content);
assert_ne!(state.playlist_version(), version);
assert_eq!(state.queue_readings()[0].duration_ms, Some(200_417));
let mut untagged = make_album_item("", "", "");
let untagged_id = untagged.id;
untagged.db_id = None;
state.add_items(vec![untagged]);
let content = state.content_version();
state.update_item_metadata(
untagged_id,
"Teachers".into(),
"Soulwax".into(),
"Soulwax".into(),
"Nite Versions".into(),
None,
);
assert_ne!(state.content_version(), content);
}
#[test]
fn test_move_item_to_reorders_playlist() {
let state = SharedPlayerState::new();
let item_a = ready_item("A");
let item_b = ready_item("B");
let item_c = ready_item("C");
let id_a = item_a.id;
let id_b = item_b.id;
let id_c = item_c.id;
state.add_items(vec![item_a, item_b, item_c]);
state.move_item_to(id_c, Some(id_a));
let (items, _) = state.snapshot_playlist();
let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
assert_eq!(titles, vec!["A", "C", "B"]);
assert_eq!(items[0].id, id_a);
assert_eq!(items[1].id, id_c);
assert_eq!(items[2].id, id_b);
}
#[test]
fn test_move_item_to_front_when_after_is_none() {
let state = SharedPlayerState::new();
let item_a = ready_item("A");
let item_b = ready_item("B");
let item_c = ready_item("C");
let id_c = item_c.id;
state.add_items(vec![item_a, item_b, item_c]);
state.move_item_to(id_c, None);
let (items, _) = state.snapshot_playlist();
let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
assert_eq!(titles, vec!["C", "A", "B"]);
}
#[test]
fn test_move_items_batch_preserves_relative_order() {
let state = SharedPlayerState::new();
let item_a = ready_item("A");
let item_b = ready_item("B");
let item_c = ready_item("C");
let item_d = ready_item("D");
let id_a = item_a.id;
let id_b = item_b.id;
let id_c = item_c.id;
let id_d = item_d.id;
state.add_items(vec![item_a, item_b, item_c, item_d]);
state.move_items(&[id_a, id_c], id_d, true);
let (items, _) = state.snapshot_playlist();
let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
assert_eq!(titles, vec!["B", "D", "A", "C"]);
assert_eq!(items[0].id, id_b);
assert_eq!(items[1].id, id_d);
assert_eq!(items[2].id, id_a);
assert_eq!(items[3].id, id_c);
}
#[test]
fn test_pending_downloads_collects_pending_with_db_id() {
let state = SharedPlayerState::new();
let mut item_a = ready_item("local");
item_a.db_id = None;
let mut item_b = pending_item("remote-1");
item_b.db_id = Some(10);
let id_b = item_b.id;
let mut item_c = ready_item("cached");
item_c.db_id = Some(20);
let mut item_d = pending_item("remote-2");
item_d.db_id = Some(30);
let id_d = item_d.id;
let item_e = pending_item("orphan");
state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
let pending = state.pending_downloads();
assert_eq!(pending.len(), 2);
assert_eq!(pending[0], (10, id_b));
assert_eq!(pending[1], (30, id_d));
state.set_cursor(Some(id_d));
assert_eq!(state.pending_downloads(), vec![(30, id_d), (10, id_b)]);
}
#[test]
fn test_item_db_id_and_load_state() {
let state = SharedPlayerState::new();
let mut item = pending_item("track");
item.db_id = Some(42);
let id = item.id;
state.add_items(vec![item]);
assert_eq!(state.item_db_id(id), Some(42));
assert!(matches!(
state.item_load_state(id),
Some(LoadState::Pending)
));
state.update_item_state(id, ItemState::Ready);
assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
}
}