Skip to main content

kithara_queue/queue/
types.rs

1use std::sync::atomic::{AtomicU64, Ordering};
2
3use kithara_bufpool::HasPool;
4use kithara_events::TrackId;
5#[cfg(test)]
6use kithara_play::PlaybackShared;
7pub use kithara_play::player::PlaybackView;
8use kithara_play::{CrossfadeSettings, ResourceSrc, SelectionPlayback};
9
10use crate::track::TrackSource;
11
12/// Transition style for a track switch.
13///
14/// Mirrors the Apple-idiomatic pattern of a namespace-style type with
15/// variants describing "what" — not "how" — so the same method
16/// signature handles both manual and auto-advance cases.
17///
18/// - [`Transition::None`] — immediate cut (0 seconds). Matches
19///   `AVQueuePlayer`'s user-initiated selection idiom.
20/// - [`Transition::Crossfade`] — use the player's configured
21///   [`PlayerImpl::crossfade_duration`](kithara_play::PlayerImpl::crossfade_duration).
22/// - [`Transition::CrossfadeWith`] — explicit override in seconds.
23#[derive(Clone, Copy, Debug, PartialEq)]
24#[non_exhaustive]
25pub enum Transition {
26    /// No crossfade; immediate cut.
27    None,
28    /// Use the player's configured crossfade duration.
29    Crossfade,
30    /// Use an explicit crossfade duration (seconds).
31    CrossfadeWith { settings: CrossfadeSettings },
32}
33
34impl Transition {
35    /// Resolve the transition to an actual crossfade duration in
36    /// seconds using `default` for [`Transition::Crossfade`].
37    #[must_use]
38    pub const fn settings(self, default: CrossfadeSettings) -> CrossfadeSettings {
39        match self {
40            Self::None => CrossfadeSettings {
41                duration: 0.0,
42                ..default
43            },
44            Self::Crossfade => default,
45            Self::CrossfadeWith { settings } => settings,
46        }
47    }
48}
49
50/// A pending-select entry: a track id waiting to be applied plus the
51/// [`Transition`] the caller asked for. Stored until loading finishes.
52#[derive(Clone, Copy, Debug)]
53pub(super) struct PendingSelect {
54    pub(super) reason: crate::AdvanceReason,
55    pub(super) settings: CrossfadeSettings,
56    pub(super) playback: SelectionPlayback,
57    pub(super) id: TrackId,
58}
59
60/// Crossfade-arm coordination state. Replaces the `u64::MAX` sentinel
61/// previously stored in `crossfade_armed_for`; "no track armed" is the
62/// explicit [`CrossfadeArm::Disarmed`] variant.
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub(super) enum CrossfadeArm {
65    Disarmed,
66    Armed { for_track: TrackId },
67}
68
69impl CrossfadeArm {
70    pub(super) const fn armed(for_track: TrackId) -> Self {
71        Self::Armed { for_track }
72    }
73
74    pub(super) fn is_armed_for(self, id: TrackId) -> bool {
75        matches!(self, Self::Armed { for_track } if for_track == id)
76    }
77}
78
79/// Pending-select phase. Replaces `Option<PendingSelect>` where `None`
80/// conflated "idle" with "absent"; [`SelectPhase::Idle`] makes the
81/// no-selection state explicit.
82#[derive(Clone, Copy, Debug)]
83pub(super) enum SelectPhase {
84    Idle,
85    Pending(PendingSelect),
86}
87
88/// Cached monotonic playback position. Replaces the `f64::NAN` sentinel
89/// stored in `cached_position`; "no value yet" is the explicit
90/// [`CachedPosition::Unknown`] variant.
91#[derive(Clone, Copy, Debug)]
92pub(super) enum CachedPosition {
93    Unknown,
94    Known { seconds: f64 },
95}
96
97impl CachedPosition {
98    /// Build a [`CachedPosition::Known`], canonicalising a `NaN` input to
99    /// [`CachedPosition::Unknown`] so the type never carries a `NaN`.
100    pub(super) const fn known(seconds: f64) -> Self {
101        if seconds.is_nan() {
102            Self::Unknown
103        } else {
104            Self::Known { seconds }
105        }
106    }
107}
108
109impl From<CachedPosition> for Option<f64> {
110    fn from(pos: CachedPosition) -> Self {
111        match pos {
112            CachedPosition::Known { seconds } => Some(seconds),
113            CachedPosition::Unknown => None,
114        }
115    }
116}
117
118/// Lock-free [`CrossfadeArm`] cell for the `tick` hot path. The
119/// `u64::MAX` bit pattern encodes [`CrossfadeArm::Disarmed`]; real ids
120/// are allocated monotonically from `0`, so the top of the range is
121/// free as the sentinel. Orderings match the original raw-`AtomicU64`
122/// accessors: `Acquire` load, `Release` store, `AcqRel` swap /
123/// compare-exchange.
124pub(super) struct AtomicTrackId(AtomicU64);
125
126impl AtomicTrackId {
127    const NONE_BITS: u64 = u64::MAX;
128
129    /// CAS [`CrossfadeArm::Disarmed`] → `Armed(track)`.
130    pub(super) fn arm_if_disarmed(&self, track: TrackId) -> bool {
131        self.0
132            .compare_exchange(
133                Self::NONE_BITS,
134                track.as_u64(),
135                Ordering::AcqRel,
136                Ordering::Acquire,
137            )
138            .is_ok()
139    }
140
141    const fn decode(bits: u64) -> CrossfadeArm {
142        if bits == Self::NONE_BITS {
143            CrossfadeArm::Disarmed
144        } else {
145            CrossfadeArm::Armed {
146                for_track: TrackId(bits),
147            }
148        }
149    }
150
151    /// CAS `Armed(track)` → [`CrossfadeArm::Disarmed`]. Returns `true` when
152    /// `track` was the armed id.
153    pub(super) fn disarm_if_matches(&self, track: TrackId) -> bool {
154        self.0
155            .compare_exchange(
156                track.as_u64(),
157                Self::NONE_BITS,
158                Ordering::AcqRel,
159                Ordering::Acquire,
160            )
161            .is_ok()
162    }
163
164    pub(super) const fn disarmed() -> Self {
165        Self(AtomicU64::new(Self::NONE_BITS))
166    }
167
168    const fn encode(arm: CrossfadeArm) -> u64 {
169        match arm {
170            CrossfadeArm::Disarmed => Self::NONE_BITS,
171            CrossfadeArm::Armed { for_track } => for_track.as_u64(),
172        }
173    }
174
175    pub(super) fn load(&self) -> CrossfadeArm {
176        Self::decode(self.0.load(Ordering::Acquire))
177    }
178
179    pub(super) fn store(&self, arm: CrossfadeArm) {
180        self.0.store(Self::encode(arm), Ordering::Release);
181    }
182
183    pub(super) fn take_if_matches(&self, track: TrackId) -> bool {
184        self.0
185            .compare_exchange(
186                track.as_u64(),
187                Self::NONE_BITS,
188                Ordering::AcqRel,
189                Ordering::Acquire,
190            )
191            .is_ok()
192    }
193}
194
195/// Lock-free [`CachedPosition`] cell for the `tick` hot path. The
196/// `f64::NAN` bit pattern encodes [`CachedPosition::Unknown`]; any `NaN`
197/// observed on load (including a `NaN` written through `store`)
198/// canonicalises back to `Unknown`.
199pub(super) struct AtomicCachedPosition(AtomicU64);
200
201impl AtomicCachedPosition {
202    pub(super) fn load(&self) -> CachedPosition {
203        let seconds = f64::from_bits(self.0.load(Ordering::Acquire));
204        if seconds.is_nan() {
205            CachedPosition::Unknown
206        } else {
207            CachedPosition::Known { seconds }
208        }
209    }
210
211    pub(super) fn store(&self, pos: CachedPosition) {
212        let bits = match pos {
213            CachedPosition::Unknown => f64::NAN.to_bits(),
214            CachedPosition::Known { seconds } => seconds.to_bits(),
215        };
216        self.0.store(bits, Ordering::Release);
217    }
218
219    pub(super) const fn unknown() -> Self {
220        Self(AtomicU64::new(f64::NAN.to_bits()))
221    }
222}
223
224/// Where a new track should land in the queue's internal `Vec`.
225#[derive(Clone, Copy, Debug)]
226pub(super) enum Placement {
227    /// Push past the tail — used by `Queue::append`.
228    Append,
229    /// Insert at a caller-resolved position — used by `Queue::insert`
230    /// after it looks up `after_id`.
231    At(usize),
232}
233
234/// Current playback position and total duration in seconds, bundled
235/// so the `should_arm_crossfade` signature does not put 3 consecutive
236/// raw float parameters at the API boundary.
237#[derive(Clone, Copy, Debug)]
238pub(crate) struct PlaybackTime {
239    pub(crate) dur: f64,
240    pub(crate) pos: f64,
241}
242
243/// Decide whether `Queue::tick` should arm the pre-end advance.
244///
245/// Returns `true` when:
246/// - `crossfade > 0` (no pre-arm without crossfade — natural-EOF advance is
247///   handled via [`PlayerEvent::ItemDidPlayToEnd`] instead), AND
248/// - `time.pos` and `time.dur` are positive (track has meaningful position + duration), AND
249/// - remaining playtime is below `crossfade` seconds, AND
250/// - we haven't already armed for this track this play-through.
251pub(crate) fn should_arm_crossfade(
252    time: PlaybackTime,
253    crossfade: f32,
254    current_id: TrackId,
255    armed_for: CrossfadeArm,
256) -> bool {
257    let PlaybackTime { pos, dur } = time;
258    crossfade > 0.0
259        && dur > 0.0
260        && pos > 0.0
261        && dur - pos <= f64::from(crossfade)
262        && !armed_for.is_armed_for(current_id)
263}
264
265pub(super) fn extract_track_name<S>(source: &TrackSource<S>) -> String
266where
267    S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
268{
269    let raw = match source {
270        TrackSource::Uri(s) => s.as_str(),
271        TrackSource::Config(cfg) => return name_from_src(cfg.source()),
272    };
273    name_from_raw(raw)
274}
275
276fn name_from_src(src: &ResourceSrc) -> String {
277    match src {
278        ResourceSrc::Url(url) => {
279            let path = url.path();
280            name_from_raw(path)
281        }
282        ResourceSrc::Path(p) => p.file_name().map_or_else(
283            || "Unknown".to_string(),
284            |n| n.to_string_lossy().into_owned(),
285        ),
286    }
287}
288
289fn name_from_raw(s: &str) -> String {
290    s.rsplit('/')
291        .find(|seg| !seg.is_empty())
292        .unwrap_or("Unknown")
293        .to_string()
294}
295
296#[cfg(test)]
297mod tests {
298    use std::sync::atomic::Ordering;
299
300    use kithara_test_utils::kithara;
301
302    use super::*;
303
304    #[kithara::test]
305    fn atomic_track_id_disarmed_loads_disarmed() {
306        let cell = AtomicTrackId::disarmed();
307        assert_eq!(cell.load(), CrossfadeArm::Disarmed);
308    }
309
310    #[kithara::test]
311    fn atomic_track_id_take_if_matches_only_disarms_matching_track() {
312        let cell = AtomicTrackId::disarmed();
313        cell.store(CrossfadeArm::armed(TrackId(7)));
314        assert_eq!(
315            cell.load(),
316            CrossfadeArm::Armed {
317                for_track: TrackId(7),
318            }
319        );
320        assert!(!cell.take_if_matches(TrackId(8)));
321        assert_eq!(
322            cell.load(),
323            CrossfadeArm::Armed {
324                for_track: TrackId(7),
325            }
326        );
327        assert!(cell.take_if_matches(TrackId(7)));
328        assert_eq!(cell.load(), CrossfadeArm::Disarmed);
329    }
330
331    #[kithara::test]
332    fn atomic_track_id_cas_arm_then_disarm() {
333        let cell = AtomicTrackId::disarmed();
334        cell.arm_if_disarmed(TrackId(3));
335        cell.arm_if_disarmed(TrackId(4));
336        assert_eq!(
337            cell.load(),
338            CrossfadeArm::Armed {
339                for_track: TrackId(3),
340            },
341            "a second arm must not replace the armed track"
342        );
343        assert!(!cell.disarm_if_matches(TrackId(4)));
344        assert!(cell.disarm_if_matches(TrackId(3)));
345        assert_eq!(cell.load(), CrossfadeArm::Disarmed);
346    }
347
348    #[kithara::test]
349    fn atomic_cached_position_unknown_loads_none() {
350        let cell = AtomicCachedPosition::unknown();
351        assert_eq!(Option::<f64>::from(cell.load()), None);
352    }
353
354    #[kithara::test]
355    fn atomic_cached_position_round_trip_zero() {
356        let cell = AtomicCachedPosition::unknown();
357        cell.store(CachedPosition::known(0.0));
358        assert_eq!(Option::<f64>::from(cell.load()), Some(0.0));
359    }
360
361    #[kithara::test]
362    fn cached_position_known_nan_canonicalises_to_unknown() {
363        assert!(matches!(
364            CachedPosition::known(f64::NAN),
365            CachedPosition::Unknown
366        ));
367    }
368
369    fn view_of(frontier: f64, cached: f64) -> PlaybackView {
370        let shared = PlaybackShared::default();
371        shared.duration.store(200.0, Ordering::Relaxed);
372        shared.frontier.store(frontier, Ordering::Relaxed);
373        shared.cached.store(cached, Ordering::Relaxed);
374        PlaybackView::from(shared.snapshot())
375    }
376
377    /// A fully downloaded track must report its cached span, not the sliver
378    /// the decoder has produced — that span is what a host progress bar and
379    /// `loadedTimeRanges` mean by "available without more network".
380    #[kithara::test]
381    fn buffered_covers_the_cached_span() {
382        assert_eq!(view_of(4.0, 120.0).buffered, Some(120.0));
383    }
384
385    /// The frontier is a floor, not a value the cached span replaces: a
386    /// reported window that falls behind the playhead makes the host pause
387    /// into a buffering deadlock.
388    #[kithara::test]
389    fn buffered_never_falls_behind_the_decoded_frontier() {
390        assert_eq!(view_of(90.0, 12.0).buffered, Some(90.0));
391    }
392
393    #[kithara::test]
394    fn buffered_is_zero_when_nothing_is_available() {
395        assert_eq!(view_of(0.0, 0.0).buffered, Some(0.0));
396    }
397
398    #[kithara::test]
399    fn crossfade_arm_is_armed_for_matches_track() {
400        let arm = CrossfadeArm::armed(TrackId(2));
401        assert!(arm.is_armed_for(TrackId(2)));
402        assert!(!arm.is_armed_for(TrackId(3)));
403    }
404
405    #[kithara::test]
406    fn select_phase_pending_carries_captured_policy() {
407        let phase = SelectPhase::Pending(PendingSelect {
408            id: TrackId(5),
409            settings: CrossfadeSettings {
410                duration: 0.0,
411                ..CrossfadeSettings::default()
412            },
413            playback: SelectionPlayback::Play,
414            reason: crate::AdvanceReason::UserSelect,
415        });
416        match phase {
417            SelectPhase::Pending(p) => {
418                assert_eq!(p.id, TrackId(5));
419                assert_eq!(p.settings.duration, 0.0);
420                assert_eq!(p.playback, SelectionPlayback::Play);
421                assert_eq!(p.reason, crate::AdvanceReason::UserSelect);
422            }
423            SelectPhase::Idle => panic!("expected Pending"),
424        }
425    }
426}