Skip to main content

kithara_queue/queue/
lifecycle.rs

1use std::sync::PoisonError;
2
3use kithara_bufpool::HasPool;
4use kithara_events::TrackId;
5use kithara_play::SelectionPlayback;
6use smallvec::SmallVec;
7
8use super::{
9    QueueControl,
10    types::{
11        CachedPosition, CrossfadeArm, PendingSelect, Placement, SelectPhase, Transition,
12        extract_track_name,
13    },
14};
15use crate::{
16    attempts::LoadClass,
17    error::QueueError,
18    event::{AdvanceReason, QueueEvent},
19    navigation::{NavigationState, PlaybackOrder},
20    track::{TrackRecord, TrackSource},
21};
22
23impl<S> QueueControl<S>
24where
25    S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
26{
27    /// Append a track. Loading starts immediately in the background.
28    /// The id is allocated from the global counter via
29    /// [`TrackId::allocate`]; use [`Self::append_with_id`] when the
30    /// caller owns the id (FFI item pre-allocation).
31    ///
32    /// # Errors
33    ///
34    /// Returns [`QueueError::Play`] after the resident player is closed.
35    pub fn append<T: Into<TrackSource<S>>>(&self, source: T) -> Result<TrackId, QueueError> {
36        let source = source.into();
37        self.with_open(|queue| queue.insert_entry(TrackId::allocate(), source, Placement::Append))
38            .map_err(QueueError::from)
39    }
40
41    /// Append a track with a caller-supplied id. The id MUST come from
42    /// [`TrackId::allocate`] so it stays inside the process-wide
43    /// monotonic address space. Used by the FFI layer where the item
44    /// reserves its id at construction and surfaces it as `audioId`
45    /// before insert.
46    ///
47    /// # Errors
48    ///
49    /// Returns [`QueueError::Play`] after the resident player is closed.
50    pub fn append_with_id<T: Into<TrackSource<S>>>(
51        &self,
52        id: TrackId,
53        source: T,
54    ) -> Result<TrackId, QueueError> {
55        let source = source.into();
56        self.with_open(|queue| queue.insert_entry(id, source, Placement::Append))
57            .map_err(QueueError::from)
58    }
59
60    /// Remove all tracks from the queue. Dropping the records aborts
61    /// their in-flight loads.
62    pub fn clear(&self) {
63        self.command(Self::clear_inner);
64    }
65
66    fn clear_inner(&self) {
67        let ids: Vec<TrackId> = {
68            let _apply = self.lock_select_apply();
69            let mut guard = self.lock_tracks_mut();
70            let ids = guard.iter().map(|r| r.id).collect();
71            guard.clear();
72            drop(guard);
73
74            *self.lock_pending_select_mut() = SelectPhase::Idle;
75            let mut navigation = self.lock_navigation_mut();
76            let repeat = navigation.repeat_mode();
77            let order = navigation.playback_order();
78            *navigation = NavigationState::new(navigation.history_limit());
79            navigation.set_repeat(repeat);
80            navigation.set_playback_order(order, &[]);
81            drop(navigation);
82            self.write_armed_for(CrossfadeArm::Disarmed);
83            self.write_cached_position(CachedPosition::Unknown);
84            self.autoplay_target.store(CrossfadeArm::Disarmed);
85            self.player.remove_all_items();
86            ids
87        };
88        *self
89            .player_rx
90            .lock()
91            .unwrap_or_else(PoisonError::into_inner) = self.bus.subscribe();
92        for id in ids {
93            self.bus.publish(QueueEvent::TrackRemoved { id });
94        }
95    }
96
97    /// Insert a track after the given id, or at the head when `after` is
98    /// `None`. Loading starts immediately.
99    ///
100    /// # Errors
101    /// Returns [`QueueError::UnknownTrackId`] if `after` does not match any
102    /// track.
103    pub fn insert<T: Into<TrackSource<S>>>(
104        &self,
105        source: T,
106        after: Option<TrackId>,
107    ) -> Result<TrackId, QueueError> {
108        let source = source.into();
109        self.with_open_result(|queue| {
110            queue.insert_with_id_inner(TrackId::allocate(), source, after)
111        })
112    }
113
114    /// Inserts a resolved track placement into queue state and starts loading.
115    pub(super) fn insert_entry(
116        &self,
117        id: TrackId,
118        source: TrackSource<S>,
119        placement: Placement,
120    ) -> TrackId {
121        let record = TrackRecord::new(id, extract_track_name(&source), source.clone());
122        if self.current().is_none() && self.autoplay_target.arm_if_disarmed(id) {
123            self.override_pending_select(PendingSelect {
124                id,
125                settings: Transition::None.settings(self.crossfade_settings()),
126                playback: if self.should_autoplay {
127                    SelectionPlayback::Play
128                } else {
129                    SelectionPlayback::Pause
130                },
131                reason: AdvanceReason::InitialLoad,
132            });
133        }
134
135        let index = {
136            let mut guard = self.lock_tracks_mut();
137            match placement {
138                Placement::Append => {
139                    guard.push(record);
140                    guard.len() - 1
141                }
142                Placement::At(pos) => {
143                    guard.insert(pos, record);
144                    pos
145                }
146            }
147        };
148        self.player.reserve_slots(self.len());
149        self.bus.publish(QueueEvent::TrackAdded { id, index });
150        let ids = self
151            .tracks()
152            .into_iter()
153            .map(|track| track.id)
154            .collect::<SmallVec<[_; 16]>>();
155        let mut navigation = self.lock_navigation_mut();
156        navigation.reconcile(&ids);
157        navigation.insert(id);
158        drop(navigation);
159        self.spawn_apply_after_load(id, source, LoadClass::Prefetch);
160        id
161    }
162
163    /// Insert a track with a caller-supplied id. See
164    /// [`Self::append_with_id`] for why the id MUST come from
165    /// [`TrackId::allocate`].
166    ///
167    /// # Errors
168    /// Returns [`QueueError::UnknownTrackId`] if `after` does not match
169    /// any track.
170    pub fn insert_with_id<T: Into<TrackSource<S>>>(
171        &self,
172        id: TrackId,
173        source: T,
174        after: Option<TrackId>,
175    ) -> Result<TrackId, QueueError> {
176        let source = source.into();
177        self.with_open_result(|queue| queue.insert_with_id_inner(id, source, after))
178    }
179
180    fn insert_with_id_inner(
181        &self,
182        id: TrackId,
183        source: TrackSource<S>,
184        after: Option<TrackId>,
185    ) -> Result<TrackId, QueueError> {
186        let pos = {
187            let guard = self.lock_tracks();
188            match after {
189                None => 0,
190                Some(after_id) => guard
191                    .iter()
192                    .position(|e| e.id == after_id)
193                    .map(|i| i + 1)
194                    .ok_or(QueueError::UnknownTrackId(after_id))?,
195            }
196        };
197        Ok(self.insert_entry(id, source, Placement::At(pos)))
198    }
199
200    /// Remove a track from the queue by id.
201    ///
202    /// If the removed track is currently playing:
203    /// - with tracks remaining → switches to the next (or previous if
204    ///   we were at the tail) with an immediate cut.
205    /// - with no tracks remaining → pauses the player.
206    ///
207    /// # Errors
208    /// Returns [`QueueError::UnknownTrackId`] if `id` is not in the queue.
209    pub fn remove(&self, id: TrackId) -> Result<(), QueueError> {
210        self.with_open_result(|queue| queue.remove_inner(id))
211    }
212
213    fn remove_inner(&self, id: TrackId) -> Result<(), QueueError> {
214        let was_current = self.current().map(|e| e.id) == Some(id);
215        let playback = if self.player.is_playing() {
216            SelectionPlayback::Play
217        } else {
218            SelectionPlayback::Pause
219        };
220        let successor_id = if was_current {
221            let guard = self.lock_tracks();
222            let pos = guard.iter().position(|e| e.id == id);
223            let result = pos.and_then(|p| {
224                let next = guard.get(p + 1);
225                let prev = if p > 0 { guard.get(p - 1) } else { None };
226                next.or(prev).map(|e| e.id)
227            });
228            drop(guard);
229            result
230        } else {
231            None
232        };
233
234        let index = {
235            let mut guard = self.lock_tracks_mut();
236            let pos = guard
237                .iter()
238                .position(|e| e.id == id)
239                .ok_or(QueueError::UnknownTrackId(id))?;
240            guard.remove(pos);
241            pos
242        };
243        let _ = self.player.remove_at(index)?;
244        self.bus.publish(QueueEvent::TrackRemoved { id });
245
246        let entries = self.tracks();
247        let ids = entries
248            .iter()
249            .map(|entry| entry.id)
250            .collect::<SmallVec<[_; 16]>>();
251        let order = self.lock_navigation().playback_order();
252        self.lock_navigation_mut().reconcile(&ids);
253
254        if was_current {
255            let replacement = match order {
256                PlaybackOrder::Sequential => {
257                    successor_id.filter(|candidate| ids.contains(candidate))
258                }
259                PlaybackOrder::Shuffle => self.lock_navigation_mut().next(&ids, false, false),
260            };
261            if let Some(next) = replacement {
262                self.select_with(
263                    next,
264                    Transition::None,
265                    AdvanceReason::RemovedCurrent,
266                    playback,
267                )?;
268            } else {
269                self.player.pause();
270            }
271        }
272        Ok(())
273    }
274
275    /// Replace the entire queue with the given sources.
276    pub fn set_tracks<I, T>(&self, sources: I)
277    where
278        I: IntoIterator<Item = T>,
279        T: Into<TrackSource<S>>,
280    {
281        self.command(|queue| {
282            queue.clear_inner();
283            for source in sources {
284                queue.insert_entry(TrackId::allocate(), source.into(), Placement::Append);
285            }
286        });
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use kithara_platform::sync::Arc;
293    use kithara_play::{ItemRole, PlayerEvent, SlotId, TrackRef};
294    use kithara_test_utils::kithara;
295
296    use super::*;
297    use crate::{
298        event::QueueEvent,
299        queue::state::tests::{make_queue, wait_for_queue_event},
300    };
301
302    fn append(queue: &crate::Queue<crate::test_pools::TestPools>, source: &str) -> TrackId {
303        queue
304            .append(source)
305            .expect("BUG: open queue must accept a track")
306    }
307
308    #[kithara::test(tokio)]
309    async fn len_is_empty_reflect_append() {
310        let queue = make_queue();
311        assert!(queue.is_empty());
312        let _ = append(&queue, "https://example.com/a.mp3");
313        let _ = append(&queue, "https://example.com/b.mp3");
314        assert_eq!(queue.len(), 2);
315    }
316
317    #[kithara::test(tokio)]
318    async fn append_returns_monotonic_ids_and_emits_track_added() {
319        let queue = make_queue();
320        let mut rx = queue.subscribe();
321        let a = append(&queue, "https://example.com/a.mp3");
322        let b = append(&queue, "https://example.com/b.mp3");
323        assert_ne!(a, b);
324        assert!(a.as_u64() < b.as_u64());
325
326        let mut seen = 0;
327        while wait_for_queue_event(
328            &mut rx,
329            |ev| matches!(ev, QueueEvent::TrackAdded { .. }),
330            200,
331        )
332        .await
333        {
334            seen += 1;
335            if seen == 2 {
336                break;
337            }
338        }
339        assert_eq!(seen, 2);
340    }
341
342    #[kithara::test(tokio)]
343    async fn remove_drops_from_queue_and_emits() {
344        let queue = make_queue();
345        let a = append(&queue, "https://example.com/a.mp3");
346        let _b = append(&queue, "https://example.com/b.mp3");
347        let mut rx = queue.subscribe();
348
349        queue
350            .remove(a)
351            .expect("BUG: just-appended track must be removable");
352        assert_eq!(queue.len(), 1);
353        let saw_removed = wait_for_queue_event(
354            &mut rx,
355            |ev| matches!(ev, QueueEvent::TrackRemoved { id } if id == &a),
356            300,
357        )
358        .await;
359        assert!(saw_removed);
360    }
361
362    #[kithara::test(tokio)]
363    async fn clear_empties_queue() {
364        let queue = make_queue();
365        let _a = append(&queue, "https://example.com/a.mp3");
366        let _b = append(&queue, "https://example.com/b.mp3");
367        assert_eq!(queue.len(), 2);
368        queue.clear();
369        assert_eq!(queue.len(), 0);
370    }
371
372    #[kithara::test(tokio)]
373    async fn clear_discards_old_eof_before_reinsert() {
374        let queue = make_queue();
375        let old = queue
376            .append("https://example.com/old.mp3")
377            .expect("open queue accepts a track");
378        queue.lock_navigation_mut().select(old, &[old]);
379        queue.player.bus().publish(PlayerEvent::ItemDidPlayToEnd {
380            item: ItemRole::Leading(TrackRef::new(
381                old,
382                SlotId::new(0),
383                Arc::from(format!("test://memory/{}", old.as_u64())),
384            )),
385        });
386
387        queue.clear();
388        let replacement = queue
389            .append("https://example.com/replacement.mp3")
390            .expect("open queue accepts a replacement track");
391        queue
392            .lock_navigation_mut()
393            .select(replacement, &[replacement]);
394        queue.player.set_rate(1.0);
395
396        queue
397            .tick()
398            .expect("tick must accept a freshly reinserted queue");
399
400        assert_eq!(
401            queue.current().map(|entry| entry.id),
402            Some(replacement),
403            "an EOF queued before clear must not end the replacement queue"
404        );
405    }
406
407    #[kithara::test(tokio)]
408    async fn set_tracks_replaces_queue() {
409        let queue = make_queue();
410        let _a = append(&queue, "https://example.com/a.mp3");
411        queue.set_tracks([
412            "https://example.com/1.mp3",
413            "https://example.com/2.mp3",
414            "https://example.com/3.mp3",
415        ]);
416        assert_eq!(queue.len(), 3);
417    }
418
419    #[kithara::test(tokio)]
420    async fn insert_after_id_places_next() {
421        let queue = make_queue();
422        let a = append(&queue, "https://example.com/a.mp3");
423        let b = append(&queue, "https://example.com/b.mp3");
424        let mid = queue
425            .insert("https://example.com/mid.mp3", Some(a))
426            .expect("BUG: insert relative to existing track");
427        let snapshot = queue.tracks();
428        let ids: Vec<TrackId> = snapshot.iter().map(|e| e.id).collect();
429        assert_eq!(ids, vec![a, mid, b]);
430    }
431
432    #[kithara::test(tokio)]
433    async fn track_source_is_keyed_by_id_across_removal() {
434        let queue = make_queue();
435        let a = append(&queue, "https://example.com/a.mp3");
436        let b = append(&queue, "https://example.com/b.mp3");
437
438        assert_eq!(
439            queue
440                .track_source(a)
441                .and_then(|s| s.uri().map(str::to_string)),
442            Some("https://example.com/a.mp3".to_string()),
443            "source resolves by identity"
444        );
445
446        // Removing an earlier track must not shift which source `b` resolves
447        // to, and the removed id must no longer have a source.
448        queue.remove(a).expect("BUG: remove existing track");
449        assert!(
450            queue.track_source(a).is_none(),
451            "removed track has no source"
452        );
453        assert_eq!(
454            queue
455                .track_source(b)
456                .and_then(|s| s.uri().map(str::to_string)),
457            Some("https://example.com/b.mp3".to_string()),
458            "surviving track still resolves to its own source by id"
459        );
460    }
461}