Skip to main content

kithara_queue/queue/
playback.rs

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    /// Whether the user has paused playback.
28    ///
29    /// Reads the Player's explicit paused phase, not its effective rate or
30    /// live output state: both become inactive at natural EOF without turning
31    /// that EOF into a user pause.
32    pub(super) fn is_paused(&self) -> bool {
33        self.player.is_paused()
34    }
35
36    /// Start the next-track crossfade ahead of end-of-track when the
37    /// remaining playtime drops below the configured crossfade window,
38    /// so the two tracks actually overlap. `ItemDidPlayToEnd` alone
39    /// fires after the first track is already silent — too late for a
40    /// real crossfade.
41    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    /// Platform audio-route changed while playback may be active.
65    ///
66    /// Recreates the native output stream below the queue without
67    /// changing queue state, current item, or track loading.
68    ///
69    /// # Errors
70    ///
71    /// Returns [`QueueError`] when the underlying player cannot restart
72    /// the active audio route.
73    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    /// The platform interrupted, or released, the audio output.
79    ///
80    /// Recording the fact is all this does: an interruption leaves the native
81    /// output unscheduled, and restoring it is the route-invalidation path.
82    pub fn notify_interruption(&self, kind: InterruptionKind) {
83        self.command(|queue| queue.player.notify_interruption(kind));
84    }
85
86    /// Pause playback and freeze the queue-visible head position.
87    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    /// Starts playback, marking a consumed slot or retaining the selection until loading finishes.
101    /// Reconciliation is serialized with load completion.
102    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    /// Single coherent read of the player's live playback state.
165    ///
166    /// Pollers (the FFI time thread, `snapshot`) get position, duration,
167    /// decoded frontier, and the playing flag from one call instead of
168    /// several separate accessors. The player-sourced fields come from one
169    /// [`PlaybackSnapshot`](kithara_play::PlaybackSnapshot) via its `From`
170    /// conversion; `position` is then replaced with this queue's cached,
171    /// 0.0-smoothed value.
172    #[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    /// Resumes seeking after the last track plays to natural EOF and the navigation cursor runs off
188    /// the end, leaving `current()` at `None`.
189    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    /// Lower or restore the whole session output under a competing sound,
210    /// such as a call or a navigation prompt.
211    ///
212    /// # Errors
213    ///
214    /// Returns [`QueueError`] when the session rejects the change.
215    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        /// Minimum position threshold used to suppress spurious 0.0 reports
235        /// on pause/resume. Values above this are considered a valid
236        /// non-zero position.
237        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            /// Latest monotonic playback position for the current track in
256            /// seconds. Updated on every [`Self::tick`]; skips transient 0.0
257            /// samples the engine produces on pause/resume so downstream UIs
258            /// see stable values.
259            #[must_use]
260            #[into]
261            #[call(read_cached_position)]
262            pub fn position_seconds(&self) -> Option<f64>;
263
264            /// Seek within the currently-playing track.
265            ///
266            /// Seek-hang detection is not handled here: the audio pipeline's
267            /// own `#[hang_watchdog]` instrumentation (e.g. `Audio::read`,
268            /// `Stream::read`, `decode_next_chunk`) already panics with a
269            /// stacktrace and context dump when no progress is observed. Adding
270            /// a second Queue-level watchdog would just duplicate those panics.
271            ///
272            /// Returns the typed [`SeekOutcome`](kithara_play::SeekOutcome) — either
273            /// `Landed` with the requested target (the actual landed position is
274            /// reconciled by the worker after applying the seek; this call returns
275            /// the optimistic outcome) or `PastEof` if the target is beyond the
276            /// known track duration.
277            ///
278            /// # Errors
279            /// Returns [`QueueError::Play`] if the player reports a seek failure.
280            #[expr($.map_err(QueueError::from))]
281            #[call(seek_player)]
282            pub fn seek(&self, seconds: f64) -> Result<SeekOutcome, QueueError>;
283
284            /// Periodic tick: drives `PlayerImpl::tick` and drains queued engine
285            /// events to act on `ItemDidPlayToEnd` (filtered) and forward
286            /// `CurrentItemChanged` as
287            /// [`QueueEvent::CurrentTrackChanged`](crate::event::QueueEvent::CurrentTrackChanged).
288            ///
289            /// # Errors
290            /// Forwards `PlayError` from `PlayerImpl::tick`.
291            #[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}