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}