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#[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 pub(super) select_apply: Arc<Mutex<()>>,
51 pub(super) tracks: Arc<Tracks<S>>,
57 pub(super) cached_position: AtomicCachedPosition,
64 pub(super) autoplay_target: AtomicTrackId,
67 pub(super) crossfade_armed_for: AtomicTrackId,
76 pub(super) shutdown: CancelToken,
78 pub(super) bus: EventBus,
79 pub(super) action_at_item_end: Mutex<ActionAtItemEnd>,
80 pub(super) admission: Mutex<()>,
82 pub(super) crossfade_settings: Mutex<CrossfadeSettings>,
83 pub(super) player_rx: Mutex<EventReceiver<PlayerBusEvent>>,
87 pub(super) should_autoplay: bool,
88}
89
90#[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
100pub 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 #[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 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 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 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 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 #[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}