Skip to main content

kithara_queue/
navigation.rs

1use std::collections::VecDeque;
2
3use kithara_events::TrackId;
4use rand::{SeedableRng, rngs::StdRng, seq::SliceRandom};
5
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
7#[non_exhaustive]
8pub enum PlaybackOrder {
9    #[default]
10    Sequential,
11    Shuffle,
12}
13
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
15#[non_exhaustive]
16pub enum ActionAtItemEnd {
17    #[default]
18    Advance,
19    Pause,
20    None,
21}
22
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum RepeatMode {
26    #[default]
27    Off,
28    One,
29    All,
30}
31
32#[derive(Debug, fieldwork::Fieldwork)]
33#[fieldwork(opt_in, get)]
34pub struct NavigationState {
35    #[field(get, copy)]
36    current: Option<TrackId>,
37    #[field(get, copy)]
38    playback_order: PlaybackOrder,
39    #[field(get, copy, set = set_repeat)]
40    repeat_mode: RepeatMode,
41    rng: StdRng,
42    bag: Vec<TrackId>,
43    history: VecDeque<TrackId>,
44    #[field(get, copy)]
45    history_limit: usize,
46}
47
48impl NavigationState {
49    #[must_use]
50    pub fn new(history_limit: usize) -> Self {
51        Self::with_rng(history_limit, StdRng::from_rng(&mut rand::rng()))
52    }
53
54    pub(crate) fn finish(&mut self) {
55        if let Some(current) = self.current.take() {
56            self.push_history(current);
57        }
58    }
59
60    fn fresh_cycle(&mut self, tracks: &[TrackId], avoid_first: Option<TrackId>) {
61        self.bag.clear();
62        self.bag.extend_from_slice(tracks);
63        self.bag.shuffle(&mut self.rng);
64        if self.bag.len() > 1
65            && let Some(avoid) = avoid_first
66            && self.bag.last() == Some(&avoid)
67        {
68            let last = self.bag.len() - 1;
69            self.bag.swap(0, last);
70        }
71    }
72
73    pub(crate) fn insert(&mut self, id: TrackId) {
74        if self.playback_order == PlaybackOrder::Shuffle
75            && self.current != Some(id)
76            && !self.bag.contains(&id)
77        {
78            self.bag.push(id);
79            self.bag.shuffle(&mut self.rng);
80        }
81    }
82
83    pub(crate) fn last_selected(&self) -> Option<TrackId> {
84        self.current.or_else(|| self.history.back().copied())
85    }
86
87    pub(crate) fn next(
88        &mut self,
89        tracks: &[TrackId],
90        allow_repeat_one: bool,
91        allow_wrap: bool,
92    ) -> Option<TrackId> {
93        let current = self.current;
94        if allow_repeat_one && self.repeat_mode == RepeatMode::One {
95            return current.filter(|id| tracks.contains(id));
96        }
97        let next = match self.playback_order {
98            PlaybackOrder::Sequential => self.next_sequential(tracks, allow_wrap),
99            PlaybackOrder::Shuffle => self.next_shuffle(tracks, allow_wrap),
100        }?;
101        if let Some(current) = current.filter(|id| *id != next) {
102            self.push_history(current);
103        }
104        self.current = Some(next);
105        Some(next)
106    }
107
108    fn next_sequential(&self, tracks: &[TrackId], allow_wrap: bool) -> Option<TrackId> {
109        let Some(current) = self.current else {
110            return tracks.first().copied();
111        };
112        let index = tracks.iter().position(|id| *id == current)?;
113        tracks
114            .get(index + 1)
115            .copied()
116            .or_else(|| allow_wrap.then(|| tracks.first().copied()).flatten())
117    }
118
119    fn next_shuffle(&mut self, tracks: &[TrackId], allow_wrap: bool) -> Option<TrackId> {
120        self.bag.retain(|id| tracks.contains(id));
121        if self.bag.is_empty() {
122            if !allow_wrap && self.current.is_some() {
123                return None;
124            }
125            self.fresh_cycle(tracks, self.current);
126        }
127        self.bag.pop()
128    }
129
130    pub(crate) fn peek_next(&self, tracks: &[TrackId]) -> Option<TrackId> {
131        match self.playback_order {
132            PlaybackOrder::Sequential => {
133                self.next_sequential(tracks, self.repeat_mode == RepeatMode::All)
134            }
135            PlaybackOrder::Shuffle => self
136                .bag
137                .iter()
138                .rev()
139                .find(|id| tracks.contains(id))
140                .copied(),
141        }
142    }
143
144    pub(crate) fn prev(&mut self, tracks: &[TrackId]) -> Option<TrackId> {
145        while let Some(previous) = self.history.pop_back() {
146            if tracks.contains(&previous) {
147                self.current = Some(previous);
148                self.bag.retain(|candidate| *candidate != previous);
149                return Some(previous);
150            }
151        }
152        None
153    }
154
155    fn push_history(&mut self, id: TrackId) {
156        if self.history.back() == Some(&id) {
157            return;
158        }
159        if self.history.len() >= self.history_limit {
160            self.history.pop_front();
161        }
162        self.history.push_back(id);
163    }
164
165    pub(crate) fn reconcile(&mut self, tracks: &[TrackId]) {
166        self.history.retain(|id| tracks.contains(id));
167        self.bag.retain(|id| tracks.contains(id));
168        if self.current.is_some_and(|id| !tracks.contains(&id)) {
169            self.current = None;
170        }
171    }
172
173    pub(crate) fn select(&mut self, id: TrackId, tracks: &[TrackId]) {
174        if let Some(current) = self.current
175            && current != id
176        {
177            self.push_history(current);
178        }
179        self.current = Some(id);
180        if self.playback_order == PlaybackOrder::Shuffle {
181            if self.bag.is_empty() {
182                self.fresh_cycle(tracks, Some(id));
183            }
184            self.bag.retain(|candidate| *candidate != id);
185        }
186    }
187
188    pub(crate) fn set_playback_order(&mut self, order: PlaybackOrder, tracks: &[TrackId]) {
189        if self.playback_order == order {
190            return;
191        }
192        self.playback_order = order;
193        self.history.clear();
194        self.fresh_cycle(tracks, self.current);
195    }
196
197    fn with_rng(history_limit: usize, rng: StdRng) -> Self {
198        Self {
199            history_limit,
200            rng,
201            current: None,
202            repeat_mode: RepeatMode::Off,
203            history: VecDeque::new(),
204            bag: Vec::new(),
205            playback_order: PlaybackOrder::Sequential,
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use kithara_test_utils::kithara;
213
214    use super::*;
215
216    fn ids() -> [TrackId; 4] {
217        [TrackId(1), TrackId(2), TrackId(3), TrackId(4)]
218    }
219
220    fn nav() -> NavigationState {
221        NavigationState::with_rng(16, StdRng::seed_from_u64(7))
222    }
223
224    #[kithara::test]
225    fn sequential_repeat_and_history_are_typed() {
226        let tracks = ids();
227        let mut nav = nav();
228        assert_eq!(nav.next(&tracks, true, false), Some(tracks[0]));
229        assert_eq!(nav.next(&tracks, true, false), Some(tracks[1]));
230        assert_eq!(nav.prev(&tracks), Some(tracks[0]));
231        nav.set_repeat(RepeatMode::One);
232        assert_eq!(nav.next(&tracks, true, false), Some(tracks[0]));
233        assert_eq!(nav.next(&tracks, false, false), Some(tracks[1]));
234    }
235
236    #[kithara::test]
237    fn shuffle_has_no_cycle_or_boundary_duplicates() {
238        let tracks = ids();
239        let mut nav = nav();
240        nav.set_playback_order(PlaybackOrder::Shuffle, &tracks);
241        let first: Vec<_> = (0..tracks.len())
242            .map(|_| nav.next(&tracks, false, true).expect("cycle item"))
243            .collect();
244        let second: Vec<_> = (0..tracks.len())
245            .map(|_| nav.next(&tracks, false, true).expect("cycle item"))
246            .collect();
247        let mut first_unique = first.clone();
248        first_unique.sort_by_key(|id| id.as_u64());
249        first_unique.dedup();
250        assert_eq!(first_unique.len(), tracks.len());
251        assert_ne!(first.last(), second.first());
252        assert_ne!(first, second, "RNG state must advance between cycles");
253    }
254
255    #[kithara::test]
256    fn explicit_selection_and_removal_reconcile_shuffle() {
257        let tracks = ids();
258        let mut nav = nav();
259        nav.set_playback_order(PlaybackOrder::Shuffle, &tracks);
260        nav.select(tracks[2], &tracks);
261        assert!(!nav.bag.contains(&tracks[2]));
262        let remaining = [tracks[0], tracks[2], tracks[3]];
263        nav.reconcile(&remaining);
264        assert!(!nav.bag.contains(&tracks[1]));
265    }
266
267    #[kithara::test]
268    fn shuffle_handles_empty_and_single_item_cycles() {
269        let mut nav = nav();
270        nav.set_playback_order(PlaybackOrder::Shuffle, &[]);
271        assert_eq!(nav.next(&[], false, true), None);
272
273        let only = [TrackId(9)];
274        assert_eq!(nav.next(&only, false, true), Some(only[0]));
275        assert_eq!(nav.next(&only, false, false), None);
276        assert_eq!(nav.next(&only, false, true), Some(only[0]));
277    }
278
279    #[kithara::test]
280    fn previous_uses_real_history_and_never_invents_an_item() {
281        let tracks = ids();
282        let mut nav = nav();
283        nav.set_playback_order(PlaybackOrder::Shuffle, &tracks);
284        let first = nav.next(&tracks, false, true).expect("first item");
285        let second = nav.next(&tracks, false, true).expect("second item");
286        assert_ne!(first, second);
287        assert_eq!(nav.prev(&tracks), Some(first));
288        assert_eq!(nav.prev(&tracks), None);
289    }
290
291    #[kithara::test]
292    fn insertion_joins_the_active_shuffle_cycle() {
293        let tracks = ids();
294        let mut nav = nav();
295        nav.set_playback_order(PlaybackOrder::Shuffle, &tracks[..2]);
296        let _ = nav.next(&tracks[..2], false, true);
297        nav.insert(tracks[2]);
298        assert!(nav.bag.contains(&tracks[2]));
299    }
300}