Skip to main content

kithara_queue/queue/
state.rs

1use std::{
2    ops::Deref,
3    sync::{Mutex, PoisonError},
4};
5
6use kithara_assets::{AssetStore, StorageBackend};
7use kithara_bufpool::HasPool;
8use kithara_events::{EventBus, EventReceiver, TrackId};
9use kithara_platform::{
10    CancelScope, CancelToken, sync::Arc, tokio::runtime::Handle as RuntimeHandle,
11};
12use kithara_play::{
13    CrossfadeSettings, PlayError, PlayerImpl,
14    player::{PlayerControl, PlayerControlSource},
15};
16
17use super::{
18    engine_events::PlayerBusEvent,
19    types::{AtomicCachedPosition, AtomicTrackId, CachedPosition, CrossfadeArm, SelectPhase},
20};
21use crate::{
22    config::QueueConfig,
23    loader::Loader,
24    navigation::{ActionAtItemEnd, NavigationState},
25    track::{TrackRecord, Tracks},
26};
27
28/// AVQueuePlayer-analogue orchestration facade.
29///
30/// Owns a [`PlayerImpl`] and a private async track loader, plus
31/// queue-level state (ordered tracks, navigation, pending-select).
32/// Publishes [`QueueEvent`](crate::event::QueueEvent) on the shared
33/// [`EventBus`] alongside player / audio / hls / file events so
34/// [`Queue::subscribe`] returns a single unified stream.
35#[doc(hidden)]
36pub struct QueueRuntime<S>
37where
38    S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
39{
40    pub(super) loader: Arc<Loader<S>>,
41    pub(super) navigation: Arc<Mutex<NavigationState>>,
42    pub(super) pending_select: Arc<Mutex<SelectPhase>>,
43    /// Serialises a selection-apply against a concurrent [`Queue::select`]. A track's
44    /// `spawn_apply_after_load` completion and a later `select` that supersedes it both
45    /// mutate the same selection state (pending, current, navigation cursor,
46    /// `TrackStatus::Cancelled`); without a single serialization point the completion
47    /// can observe-not-cancelled then `select_item` *after* the superseding select
48    /// committed, so the superseded track barges in. Held only across the synchronous
49    /// apply critical section — never across an `.await`.
50    pub(super) select_apply: Arc<Mutex<()>>,
51    /// Sole owner of the `Vec<TrackRecord>` (status, source, and live
52    /// load attempt per track). Shared with [`Loader`] through
53    /// `Arc<Tracks>`; every status transition goes through
54    /// [`Tracks::set_status`](crate::track::Tracks::set_status) so polling
55    /// and the event stream stay in sync.
56    pub(super) tracks: Arc<Tracks<S>>,
57    /// Authoritative playback position updated on every `tick`. Filters
58    /// transient 0.0 blips the engine reports on pause/resume —
59    /// downstream UIs should read from this field rather than polling
60    /// the engine directly. Read/written lock-free as a typed
61    /// [`CachedPosition`] — [`CachedPosition::Unknown`] before the first
62    /// stable sample.
63    pub(super) cached_position: AtomicCachedPosition,
64    /// Track whose load completion starts playback: the first one appended
65    /// while nothing is selected, when [`QueueConfig::should_autoplay`] is on.
66    pub(super) autoplay_target: AtomicTrackId,
67    /// Tracks the id of the track whose crossfade-advance has already
68    /// been armed during `tick()`. Prevents triggering the next-track
69    /// select repeatedly as the remaining playtime keeps ticking below
70    /// the crossfade threshold. Cleared on
71    /// [`QueueEvent::CurrentTrackChanged`](crate::event::QueueEvent::CurrentTrackChanged).
72    ///
73    /// Read/written lock-free as a typed [`CrossfadeArm`] from the tick
74    /// loop and the engine event handler.
75    pub(super) crossfade_armed_for: AtomicTrackId,
76    /// Master cancel token for queue-owned loader work.
77    pub(super) shutdown: CancelToken,
78    pub(super) bus: EventBus,
79    pub(super) action_at_item_end: Mutex<ActionAtItemEnd>,
80    /// Serializes every state-changing command against terminal close.
81    pub(super) admission: Mutex<()>,
82    pub(super) crossfade_settings: Mutex<CrossfadeSettings>,
83    /// Subscription to the shared bus; drained in `tick()` to convert
84    /// engine events into queue-level side-effects (auto-advance / current
85    /// track change forwarding).
86    pub(super) player_rx: Mutex<EventReceiver<PlayerBusEvent>>,
87    pub(super) should_autoplay: bool,
88}
89
90/// Cloneable queue command capability without beat-grid identity or topology.
91#[derive_where::derive_where(Clone; S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static)]
92pub struct QueueControl<S>
93where
94    S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
95{
96    pub(super) player: PlayerControl<S>,
97    runtime: Arc<QueueRuntime<S>>,
98}
99
100/// AVQueuePlayer-analogue orchestration facade.
101///
102/// Owns the resident player and its canonical synchronization state. Runtime
103/// commands are exposed through a separate cloneable [`QueueControl`].
104pub struct Queue<S>
105where
106    S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
107{
108    pub(super) player: PlayerImpl<S>,
109    pub(super) control: QueueControl<S>,
110}
111
112impl<S> Deref for QueueControl<S>
113where
114    S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
115{
116    type Target = QueueRuntime<S>;
117
118    fn deref(&self) -> &Self::Target {
119        &self.runtime
120    }
121}
122
123impl<S> Deref for Queue<S>
124where
125    S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
126{
127    type Target = QueueControl<S>;
128
129    fn deref(&self) -> &Self::Target {
130        &self.control
131    }
132}
133
134impl<S> Queue<S>
135where
136    S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
137{
138    /// Build a queue from a [`QueueConfig`].
139    ///
140    /// The queue takes ownership of the supplied [`PlayerImpl`]; all access to
141    /// the decorated player then goes through this facade.
142    #[must_use]
143    pub fn new(config: QueueConfig<S>) -> Self {
144        let QueueConfig {
145            player,
146            runtime,
147            store,
148            cancel: config_cancel,
149            max_concurrent_loads,
150            max_history_size,
151            prefetch_duration,
152            should_autoplay,
153            playback_order,
154            action_at_item_end,
155            crossfade_settings,
156        } = config;
157        let cancel = CancelScope::new(config_cancel).token();
158        let store = store.unwrap_or_else(|| {
159            AssetStore::builder(player.pools().clone())
160                .backend(StorageBackend::default())
161                .cancel(cancel.child())
162                .build()
163        });
164        player.set_auto_advance_enabled(false);
165        player.set_prefetch_duration(prefetch_duration);
166        player.set_crossfade_duration(crossfade_settings.duration);
167        let bus = player.bus().clone();
168        let player_control = player.control();
169        let tracks = Arc::new(Tracks::new(bus.clone()));
170        let loader = Arc::new(Loader::new(
171            player_control.clone(),
172            runtime.or_else(|| RuntimeHandle::try_current().ok()),
173            store,
174            max_concurrent_loads,
175            Arc::clone(&tracks),
176            cancel.child(),
177        ));
178        let player_rx = player.subscribe();
179        let mut navigation = NavigationState::new(max_history_size);
180        navigation.set_playback_order(playback_order, &[]);
181        let runtime = Arc::new(QueueRuntime {
182            loader,
183            tracks,
184            bus,
185            should_autoplay,
186            admission: Mutex::new(()),
187            shutdown: cancel,
188            navigation: Arc::new(Mutex::new(navigation)),
189            action_at_item_end: Mutex::new(action_at_item_end),
190            crossfade_settings: Mutex::new(crossfade_settings),
191            pending_select: Arc::new(Mutex::new(SelectPhase::Idle)),
192            select_apply: Arc::new(Mutex::new(())),
193            player_rx: Mutex::new(player_rx),
194            crossfade_armed_for: AtomicTrackId::disarmed(),
195            autoplay_target: AtomicTrackId::disarmed(),
196            cached_position: AtomicCachedPosition::unknown(),
197        });
198        Self {
199            player,
200            control: QueueControl {
201                runtime,
202                player: player_control,
203            },
204        }
205    }
206}
207
208impl<S> QueueControl<S>
209where
210    S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
211{
212    /// Close the resident player, then irreversibly cancel queue-owned work.
213    ///
214    /// # Errors
215    ///
216    /// Returns the player detach failure without cancelling the queue token;
217    /// the player control gate is reopened so the owner can retry.
218    pub fn close(&self) -> Result<(), PlayError> {
219        let _admission = self.lock_admission();
220        self.player.close()?;
221        self.shutdown.cancel();
222        Ok(())
223    }
224
225    pub(in crate::queue) fn command(&self, operation: impl FnOnce(&Self)) {
226        let _ = self.with_open(operation);
227    }
228
229    fn ensure_open(&self) -> Result<(), PlayError> {
230        if self.is_closed() {
231            Err(PlayError::Closed)
232        } else {
233            Ok(())
234        }
235    }
236
237    pub(crate) fn invalidate(&self) {
238        self.shutdown.cancel();
239    }
240
241    #[must_use]
242    pub fn is_closed(&self) -> bool {
243        self.shutdown.is_cancelled() || self.player.is_closed()
244    }
245
246    pub(in crate::queue) fn lock_admission(&self) -> std::sync::MutexGuard<'_, ()> {
247        self.admission
248            .lock()
249            .unwrap_or_else(PoisonError::into_inner)
250    }
251
252    pub(super) fn lock_navigation(&self) -> std::sync::MutexGuard<'_, NavigationState> {
253        self.navigation
254            .lock()
255            .unwrap_or_else(PoisonError::into_inner)
256    }
257
258    pub(super) fn lock_navigation_mut(&self) -> std::sync::MutexGuard<'_, NavigationState> {
259        self.navigation
260            .lock()
261            .unwrap_or_else(PoisonError::into_inner)
262    }
263
264    pub(in crate::queue) fn lock_pending_select_mut(
265        &self,
266    ) -> std::sync::MutexGuard<'_, SelectPhase> {
267        self.pending_select
268            .lock()
269            .unwrap_or_else(PoisonError::into_inner)
270    }
271
272    /// Acquire the selection-apply serialization guard (see
273    /// [`Self::select_apply`]). Taken before `tracks`/`pending_select`/
274    /// `navigation`/`player` in both `select` and the
275    /// `spawn_apply_after_load` completion, so the two cannot interleave.
276    pub(in crate::queue) fn lock_select_apply(&self) -> std::sync::MutexGuard<'_, ()> {
277        self.select_apply
278            .lock()
279            .unwrap_or_else(PoisonError::into_inner)
280    }
281
282    pub(in crate::queue) fn with_open<T>(
283        &self,
284        operation: impl FnOnce(&Self) -> T,
285    ) -> Result<T, PlayError> {
286        let _admission = self.lock_admission();
287        self.ensure_open()?;
288        Ok(operation(self))
289    }
290
291    pub(in crate::queue) fn with_open_result<T, E>(
292        &self,
293        operation: impl FnOnce(&Self) -> Result<T, E>,
294    ) -> Result<T, E>
295    where
296        E: From<PlayError>,
297    {
298        let _admission = self.lock_admission();
299        self.ensure_open().map_err(E::from)?;
300        operation(self)
301    }
302
303    delegate::delegate! {
304        to self.tracks {
305            #[call(lock)]
306            pub(super) fn lock_tracks(&self) -> std::sync::MutexGuard<'_, Vec<TrackRecord<S>>>;
307            #[call(lock)]
308            pub(super) fn lock_tracks_mut(&self) -> std::sync::MutexGuard<'_, Vec<TrackRecord<S>>>;
309            pub(super) fn set_status(&self, id: TrackId, status: crate::event::TrackStatus);
310        }
311        to self.crossfade_armed_for {
312            #[call(load)]
313            pub(super) fn read_armed_for(&self) -> CrossfadeArm;
314            #[call(take_if_matches)]
315            pub(super) fn take_armed_for_if_matches(&self, id: TrackId) -> bool;
316            #[call(store)]
317            pub(super) fn write_armed_for(&self, arm: CrossfadeArm);
318        }
319        to self.cached_position {
320            #[call(load)]
321            pub(super) fn read_cached_position(&self) -> CachedPosition;
322            #[call(store)]
323            pub(super) fn write_cached_position(&self, pos: CachedPosition);
324        }
325    }
326}
327
328impl<S> Drop for Queue<S>
329where
330    S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
331{
332    fn drop(&mut self) {
333        self.control.invalidate();
334    }
335}
336
337#[cfg(test)]
338pub(crate) mod tests {
339    use core::sync::atomic::{AtomicU64, Ordering};
340    use std::{
341        num::NonZeroU32,
342        sync::mpsc::{self, RecvTimeoutError},
343        thread,
344    };
345
346    use kithara_audio::ConsumerWakeMode;
347    use kithara_events::{Envelope, EventReceiver};
348    use kithara_platform::{
349        sync::{Arc, Mutex},
350        time::{Duration, Instant, timeout},
351    };
352    use kithara_play::{
353        AllocatedSlot, BeatGrid, Cmd, NodeInputs, PlayError, PlayWorker, PlayWorkerConfig,
354        PlayerConfig, Reply, SessionBinding, SessionDispatcher, SessionSampleRate, SharedEq,
355        SlotId, bridge::slot_channels,
356    };
357    use kithara_test_utils::kithara;
358
359    use super::*;
360    use crate::{
361        event::QueueEvent,
362        test_pools::{TestPools, pools},
363    };
364
365    pub(crate) const TEST_SAMPLE_RATE: NonZeroU32 = match NonZeroU32::new(44_100) {
366        Some(sample_rate) => sample_rate,
367        None => unreachable!(),
368    };
369
370    /// No queue test ever streams bytes, so the store is here to be wired, not
371    /// to hold anything. The default backend would map a file under the shared
372    /// temp root, which every parallel test process also owns and which Miri
373    /// cannot map at all.
374    pub(in crate::queue) fn make_store() -> AssetStore<TestPools> {
375        AssetStore::builder(pools())
376            .backend(StorageBackend::Memory)
377            .build()
378    }
379
380    pub(in crate::queue) fn make_queue() -> Queue<TestPools> {
381        Queue::new(queue_config())
382    }
383
384    struct TestSession {
385        next_slot: AtomicU64,
386        nodes: Mutex<Vec<NodeInputs>>,
387    }
388
389    impl SessionDispatcher<TestPools> for TestSession {
390        fn consumer_wake_mode(&self) -> ConsumerWakeMode {
391            ConsumerWakeMode::RealtimeDeferred
392        }
393
394        fn exec(&self, cmd: Cmd<TestPools>) -> Result<Reply, PlayError> {
395            let reply = match cmd {
396                Cmd::RegisterPlayer { .. } => {
397                    Reply::PlayerRegistered(kithara_play::session::RegisteredPlayer {
398                        id: 1,
399                        eq: SharedEq::new(10),
400                    })
401                }
402                Cmd::AllocateSlot { .. } => {
403                    let slot = SlotId::new(self.next_slot.fetch_add(1, Ordering::Relaxed));
404                    let (inputs, control) = slot_channels(SharedEq::new(10));
405                    self.nodes.lock().push(inputs);
406                    Reply::SlotAllocated(AllocatedSlot::new(control, slot))
407                }
408                Cmd::QuerySampleRate => Reply::SampleRate(SessionSampleRate::new(None, 44_100)),
409                Cmd::QueryStreamShape => Reply::StreamShape(None),
410                _ => Reply::Ok,
411            };
412            Ok(reply)
413        }
414    }
415
416    pub(crate) fn test_session() -> SessionBinding<TestPools> {
417        SessionBinding::new(
418            Arc::new(TestSession {
419                next_slot: AtomicU64::new(0),
420                nodes: Mutex::default(),
421            }),
422            TEST_SAMPLE_RATE,
423        )
424    }
425
426    fn queue_config() -> QueueConfig<TestPools> {
427        QueueConfig::builder()
428            .player(player())
429            .store(make_store())
430            .build()
431    }
432
433    fn player() -> PlayerImpl<TestPools> {
434        let worker = PlayWorker::new(PlayWorkerConfig::builder(pools()).build());
435        PlayerImpl::new(
436            PlayerConfig::builder()
437                .sample_rate(TEST_SAMPLE_RATE)
438                .worker(worker)
439                .session(test_session())
440                .build(),
441        )
442    }
443
444    pub(in crate::queue) async fn wait_for_queue_event<F>(
445        rx: &mut EventReceiver<QueueEvent>,
446        mut matches: F,
447        timeout_ms: u64,
448    ) -> bool
449    where
450        F: FnMut(&QueueEvent) -> bool,
451    {
452        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
453        loop {
454            let remaining = deadline.saturating_duration_since(Instant::now());
455            if remaining.is_zero() {
456                return false;
457            }
458            match timeout(remaining, rx.recv()).await {
459                Ok(Ok(Envelope { event: ev, .. })) if matches(&ev) => return true,
460                Ok(Ok(_)) => continue,
461                Ok(Err(_)) | Err(_) => return false,
462            }
463        }
464    }
465
466    #[kithara::test]
467    fn queue_new_constructs_without_panic() {
468        let _queue = make_queue();
469    }
470
471    #[kithara::test]
472    fn queue_preserves_the_resident_players_canonical_grid() {
473        let player = player();
474        let grid_id = player.id();
475        let snapshot = player.snapshot();
476        let queue = Queue::new(QueueConfig::builder().player(player).build());
477
478        assert_eq!(queue.id(), grid_id);
479        assert_eq!(queue.snapshot(), snapshot);
480    }
481
482    #[kithara::test]
483    fn queue_control_rejects_mutation_after_close() {
484        let queue = make_queue();
485        let control = queue.control.clone();
486
487        control.close().expect("unstarted fixture must close");
488
489        assert!(control.runtime.shutdown.is_cancelled());
490        assert!(matches!(
491            control.append("https://example.com/a.mp3"),
492            Err(crate::QueueError::Play(PlayError::Closed))
493        ));
494        assert!(queue.is_empty());
495    }
496
497    #[kithara::test]
498    fn close_waits_for_an_admitted_queue_mutation() {
499        let queue = make_queue();
500        let mutation_control = queue.control.clone();
501        let close_control = queue.control.clone();
502        let (entered_tx, entered_rx) = mpsc::channel();
503        let (release_tx, release_rx) = mpsc::channel();
504        let (mutation_tx, mutation_rx) = mpsc::channel();
505        let mutation = thread::spawn(move || {
506            let result = mutation_control.with_open(|_| {
507                entered_tx.send(()).expect("test receiver remains alive");
508                release_rx.recv().expect("test sender releases mutation");
509            });
510            mutation_tx
511                .send(result)
512                .expect("test receiver remains alive");
513        });
514
515        entered_rx
516            .recv()
517            .expect("mutation must enter the queue admission gate");
518        let (close_tx, close_rx) = mpsc::channel();
519        let close = thread::spawn(move || {
520            close_tx
521                .send(close_control.close())
522                .expect("test receiver remains alive");
523        });
524
525        // Every other wait here is on the event itself: under Miri the threads
526        // run two orders of magnitude slower, and a one-second budget made the
527        // test report a scheduling contract it had merely outrun. This one
528        // stays a timer because it asserts the absence of an event, which no
529        // amount of waiting can observe directly.
530        assert!(
531            matches!(
532                close_rx.recv_timeout(Duration::from_millis(50)),
533                Err(RecvTimeoutError::Timeout)
534            ),
535            "close must not overtake an admitted queue mutation"
536        );
537        release_tx.send(()).expect("mutation thread remains alive");
538        mutation_rx
539            .recv()
540            .expect("mutation must complete after release")
541            .expect("admitted mutation remains open");
542        close_rx
543            .recv()
544            .expect("close must complete after the mutation")
545            .expect("unstarted fixture must close");
546        mutation.join().expect("mutation thread must not panic");
547        close.join().expect("close thread must not panic");
548        assert!(queue.is_closed());
549    }
550
551    /// `PlayerImpl::set_prefetch_duration` names the queue as the canonical
552    /// owner of this knob, so what the queue's config says has to be what
553    /// the player it drives runs with.
554    #[kithara::test]
555    fn the_configured_prefetch_lead_reaches_the_player() {
556        let queue = Queue::new(
557            QueueConfig::builder()
558                .player(player())
559                .store(make_store())
560                .prefetch_duration(8.0)
561                .build(),
562        );
563
564        assert!((queue.player.prefetch_duration() - 8.0).abs() < f32::EPSILON);
565    }
566
567    #[kithara::test]
568    fn crossfade_arm_disarmed_after_construction() {
569        let queue = make_queue();
570        assert_eq!(queue.read_armed_for(), CrossfadeArm::Disarmed);
571    }
572
573    #[kithara::test]
574    fn crossfade_arm_take_only_disarms_matching_track() {
575        let queue = make_queue();
576        queue.write_armed_for(CrossfadeArm::armed(TrackId(9)));
577        assert!(!queue.take_armed_for_if_matches(TrackId(10)));
578        assert_eq!(
579            queue.read_armed_for(),
580            CrossfadeArm::Armed {
581                for_track: TrackId(9),
582            }
583        );
584        assert!(queue.take_armed_for_if_matches(TrackId(9)));
585        assert_eq!(queue.read_armed_for(), CrossfadeArm::Disarmed);
586    }
587
588    #[kithara::test]
589    fn cached_position_unknown_after_construction() {
590        let queue = make_queue();
591        assert_eq!(Option::<f64>::from(queue.read_cached_position()), None);
592    }
593
594    #[kithara::test]
595    fn cached_position_round_trips_through_queue() {
596        let queue = make_queue();
597        queue.write_cached_position(CachedPosition::known(12.5));
598        assert_eq!(
599            Option::<f64>::from(queue.read_cached_position()),
600            Some(12.5)
601        );
602    }
603
604    #[kithara::test]
605    fn select_phase_idle_after_construction() {
606        let queue = make_queue();
607        assert!(matches!(
608            *queue.lock_pending_select_mut(),
609            SelectPhase::Idle
610        ));
611    }
612}