1use kithara_bufpool::HasPool;
2use kithara_play::{
3 InterruptionKind, PlayError, SeekOutcome, SelectionPlayback, SessionDuckingMode,
4};
5use smallvec::SmallVec;
6
7use super::{
8 QueueControl,
9 types::{CachedPosition, PendingSelect, PlaybackView, SelectPhase, Transition},
10};
11use crate::{
12 attempts::LoadClass,
13 error::QueueError,
14 event::{AdvanceReason, TrackStatus},
15};
16
17impl<S> QueueControl<S>
18where
19 S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
20{
21 fn freeze_cached_position(&self) {
22 if let Some(t) = self.player.position_seconds() {
23 self.write_cached_position(CachedPosition::known(t));
24 }
25 }
26
27 pub(super) fn is_paused(&self) -> bool {
33 self.player.is_paused()
34 }
35
36 fn maybe_arm_crossfade(&self) {
42 if self.is_paused() {
43 return;
44 }
45 let crossfade = self.player.crossfade_duration();
46 let view = self.playback_view();
47 let (Some(dur), Some(pos), Some(entry)) = (view.duration, view.position, self.current())
48 else {
49 return;
50 };
51 let armed_for = self.read_armed_for();
52 let time = super::types::PlaybackTime { dur, pos };
53 if !super::types::should_arm_crossfade(time, crossfade, entry.id, armed_for) {
54 return;
55 }
56 let transition = if crossfade > 0.0 {
57 Transition::Crossfade
58 } else {
59 Transition::None
60 };
61 self.advance_loaded_successor(entry.id, transition);
62 }
63
64 pub fn notify_audio_route_changed(&self, reason: &str) -> Result<(), QueueError> {
74 self.with_open_result(|queue| queue.player.invalidate_audio_route(reason))?;
75 Ok(())
76 }
77
78 pub fn notify_interruption(&self, kind: InterruptionKind) {
83 self.command(|queue| queue.player.notify_interruption(kind));
84 }
85
86 pub fn pause(&self) {
88 self.command(|queue| {
89 queue.player.pause();
90 let mut phase = queue.lock_pending_select_mut();
91 if let SelectPhase::Pending(mut pending) = *phase {
92 pending.playback = SelectionPlayback::Pause;
93 *phase = SelectPhase::Pending(pending);
94 }
95 drop(phase);
96 queue.freeze_cached_position();
97 });
98 }
99
100 pub fn play(&self) {
103 self.command(Self::play_inner);
104 }
105
106 fn play_inner(&self) {
107 let mut phase = self.lock_pending_select_mut();
108 if let SelectPhase::Pending(mut pending) = *phase {
109 pending.playback = SelectionPlayback::Play;
110 *phase = SelectPhase::Pending(pending);
111 }
112 drop(phase);
113 self.player.play();
114
115 let _apply = self.lock_select_apply();
116 let pending = match *self.lock_pending_select_mut() {
117 SelectPhase::Pending(pending) => Some(pending),
118 SelectPhase::Idle => None,
119 };
120 let index = self.player.current_index();
121 if pending.is_none() && self.player.item_has_resource(index) {
122 return;
123 }
124 let current = {
125 let guard = self.lock_tracks();
126 pending
127 .map_or_else(
128 || guard.get(index),
129 |pending| guard.iter().find(|entry| entry.id == pending.id),
130 )
131 .map(|entry| (entry.id, entry.status.clone()))
132 };
133 let Some((id, status)) = current else {
134 return;
135 };
136 match status {
137 TrackStatus::Loaded => self.set_status(id, TrackStatus::Consumed),
138 TrackStatus::Pending | TrackStatus::Loading | TrackStatus::Slow => {
139 self.override_pending_select(pending.unwrap_or_else(|| PendingSelect {
140 id,
141 settings: Transition::None.settings(self.crossfade_settings()),
142 playback: SelectionPlayback::Play,
143 reason: AdvanceReason::UserSelect,
144 }));
145 self.promote_pending_load(id);
146 }
147 TrackStatus::Failed(_) => {
148 let Some(source) = self.tracks.source(id) else {
149 return;
150 };
151 self.override_pending_select(PendingSelect {
152 id,
153 settings: Transition::None.settings(self.crossfade_settings()),
154 playback: SelectionPlayback::Play,
155 reason: AdvanceReason::UserSelect,
156 });
157 self.set_status(id, TrackStatus::Pending);
158 self.spawn_apply_after_load(id, source, LoadClass::Interactive);
159 }
160 TrackStatus::Consumed | TrackStatus::Cancelled => {}
161 }
162 }
163
164 #[must_use]
173 pub fn playback_view(&self) -> PlaybackView {
174 let mut view = self
175 .player
176 .playback_snapshot()
177 .map(PlaybackView::from)
178 .unwrap_or_default();
179 view.position = self.position_seconds();
180 view
181 }
182
183 pub(super) fn seek_player(&self, seconds: f64) -> Result<SeekOutcome, PlayError> {
184 self.with_open_result(|queue| queue.seek_player_inner(seconds))
185 }
186
187 fn seek_player_inner(&self, seconds: f64) -> Result<SeekOutcome, PlayError> {
190 if self.current().is_none() {
191 let id = { self.lock_navigation().last_selected() };
192 if let Some(id) = id {
193 let ids = self
194 .tracks()
195 .into_iter()
196 .map(|track| track.id)
197 .collect::<SmallVec<[_; 16]>>();
198 self.lock_navigation_mut().select(id, &ids);
199 self.handle_current_item_changed();
200 }
201 }
202 let outcome = self.player.seek_seconds(seconds)?;
203 if let SeekOutcome::Landed { landed_at, .. } = outcome {
204 self.write_cached_position(CachedPosition::known(landed_at.as_secs_f64()));
205 }
206 Ok(outcome)
207 }
208
209 pub fn set_session_ducking(&self, mode: SessionDuckingMode) -> Result<(), QueueError> {
216 self.with_open_result(|queue| queue.player.set_session_ducking(mode))?;
217 Ok(())
218 }
219
220 pub(super) fn tick_player(&self) -> Result<(), PlayError> {
221 self.with_open_result(Self::tick_player_inner)
222 }
223
224 fn tick_player_inner(&self) -> Result<(), PlayError> {
225 self.player.tick()?;
226 self.player.process_notifications();
227 self.drain_player_events();
228 self.update_cached_position();
229 self.maybe_arm_crossfade();
230 Ok(())
231 }
232
233 fn update_cached_position(&self) {
234 const MIN_STABLE_POSITION_SECS: f64 = 0.5;
238
239 if self.is_paused() {
240 return;
241 }
242
243 let Some(t) = self.player.position_seconds() else {
244 return;
245 };
246 let prev = Option::<f64>::from(self.read_cached_position());
247 if t == 0.0 && prev.is_some_and(|p| p > MIN_STABLE_POSITION_SECS) {
248 return;
249 }
250 self.write_cached_position(CachedPosition::known(t));
251 }
252
253 delegate::delegate! {
254 to self {
255 #[must_use]
260 #[into]
261 #[call(read_cached_position)]
262 pub fn position_seconds(&self) -> Option<f64>;
263
264 #[expr($.map_err(QueueError::from))]
281 #[call(seek_player)]
282 pub fn seek(&self, seconds: f64) -> Result<SeekOutcome, QueueError>;
283
284 #[expr($.map_err(QueueError::from))]
292 #[call(tick_player)]
293 pub fn tick(&self) -> Result<(), QueueError>;
294 }
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use kithara_events::{SlotId, TrackId};
301 use kithara_platform::sync::Arc;
302 use kithara_play::{ItemRole, PlayerEvent, TrackRef};
303 use kithara_test_utils::kithara;
304
305 use crate::{
306 event::{QueueEvent, TrackStatus},
307 queue::{
308 state::tests::make_queue,
309 types::{CrossfadeArm, PlaybackTime, SelectPhase, should_arm_crossfade},
310 },
311 track::{TrackRecord, TrackSource},
312 };
313
314 #[kithara::test(tokio)]
315 async fn spurious_item_did_play_to_end_is_filtered() {
316 let queue = make_queue();
317 let _a = queue.append("https://example.com/a.mp3");
318 let _b = queue.append("https://example.com/b.mp3");
319
320 queue.player.bus().publish(PlayerEvent::ItemDidPlayToEnd {
321 item: ItemRole::Leading(TrackRef::new(
322 TrackId::allocate(),
323 SlotId::new(0),
324 Arc::from(""),
325 )),
326 });
327
328 queue
329 .tick()
330 .expect("BUG: tick returned error in test setup");
331
332 assert_eq!(
333 queue.lock_navigation().current(),
334 None,
335 "navigation must not have advanced"
336 );
337 }
338
339 #[kithara::test(tokio)]
340 async fn eof_after_queue_end_does_not_restart_from_first_track() {
341 let queue = make_queue();
342 let a = TrackId::allocate();
343 let b = TrackId::allocate();
344 queue.tracks.lock().extend([
345 TrackRecord::new(a, "a".into(), TrackSource::from("a")),
346 TrackRecord::new(b, "b".into(), TrackSource::from("b")),
347 ]);
348 queue.lock_navigation_mut().select(b, &[a, b]);
349 queue.lock_navigation_mut().finish();
350 let mut rx = queue.subscribe();
351
352 queue.player.bus().publish(PlayerEvent::ItemDidPlayToEnd {
353 item: ItemRole::Leading(TrackRef::new(
354 b,
355 SlotId::new(0),
356 Arc::from(format!("test://memory/{}", b.as_u64())),
357 )),
358 });
359
360 queue
361 .tick()
362 .expect("BUG: tick returned error in test setup");
363
364 assert_eq!(
365 queue.lock_navigation().current(),
366 None,
367 "stale EOF must not restart the queue"
368 );
369 let saw_ended = crate::queue::state::tests::wait_for_queue_event(
370 &mut rx,
371 |ev| matches!(ev, QueueEvent::QueueEnded),
372 200,
373 )
374 .await;
375 assert!(!saw_ended, "stale EOF must not duplicate QueueEnded");
376 }
377
378 #[kithara::test(tokio)]
379 async fn play_retries_the_current_track_after_its_prefetch_failed() {
380 let queue = make_queue();
381 let id = queue
382 .append("https://example.com/a.mp3")
383 .expect("open queue accepts a track");
384 queue.set_status(id, TrackStatus::Failed("network offline".into()));
385
386 queue.play_inner();
387
388 let SelectPhase::Pending(pending) = *queue.lock_pending_select_mut() else {
389 panic!("play must retain selection while retrying the failed track")
390 };
391 assert_eq!(pending.id, id);
392 assert_eq!(pending.playback, kithara_play::SelectionPlayback::Play);
393 }
394
395 #[kithara::test(tokio)]
396 #[case::append(false)]
397 #[case::insert(true)]
398 async fn play_promotes_the_initial_pending_prefetch(#[case] insert: bool) {
399 let queue = make_queue();
400 let id = if insert {
401 queue.insert("https://example.com/a.mp3", None)
402 } else {
403 queue.append("https://example.com/a.mp3")
404 }
405 .expect("open queue accepts a track");
406 assert!(!queue.tracks.attempt_selected(id));
407
408 queue.play();
409
410 assert!(queue.tracks.attempt_selected(id));
411 }
412
413 #[kithara::test]
414 #[case::remaining_equals_crossfade(157.0, 162.0, 5.0, TrackId(1), CrossfadeArm::Disarmed, true)]
415 #[case::remaining_below_crossfade(160.0, 162.0, 5.0, TrackId(1), CrossfadeArm::Disarmed, true)]
416 #[case::far_from_end(100.0, 162.0, 5.0, TrackId(1), CrossfadeArm::Disarmed, false)]
417 #[case::already_armed_for_same_track(
418 160.0,
419 162.0,
420 5.0,
421 TrackId(1),
422 CrossfadeArm::armed(TrackId(1)),
423 false
424 )]
425 #[case::armed_for_different_track_still_arms(
426 160.0,
427 162.0,
428 5.0,
429 TrackId(1),
430 CrossfadeArm::armed(TrackId(0)),
431 true
432 )]
433 #[case::crossfade_zero_at_tail_no_pre_arm(
434 161.9,
435 162.0,
436 0.0,
437 TrackId(1),
438 CrossfadeArm::Disarmed,
439 false
440 )]
441 #[case::crossfade_zero_quiet_middle(
442 161.0,
443 162.0,
444 0.0,
445 TrackId(1),
446 CrossfadeArm::Disarmed,
447 false
448 )]
449 #[case::zero_position_rejected(0.0, 162.0, 5.0, TrackId(1), CrossfadeArm::Disarmed, false)]
450 #[case::zero_duration_rejected(10.0, 0.0, 5.0, TrackId(1), CrossfadeArm::Disarmed, false)]
451 fn should_arm_crossfade_cases(
452 #[case] pos: f64,
453 #[case] dur: f64,
454 #[case] crossfade: f32,
455 #[case] current_id: TrackId,
456 #[case] armed_for: CrossfadeArm,
457 #[case] expected: bool,
458 ) {
459 assert_eq!(
460 should_arm_crossfade(PlaybackTime { dur, pos }, crossfade, current_id, armed_for),
461 expected
462 );
463 }
464}