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#[derive(Clone, Copy, Debug, PartialEq)]
24#[non_exhaustive]
25pub enum Transition {
26 None,
28 Crossfade,
30 CrossfadeWith { settings: CrossfadeSettings },
32}
33
34impl Transition {
35 #[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#[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#[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#[derive(Clone, Copy, Debug)]
83pub(super) enum SelectPhase {
84 Idle,
85 Pending(PendingSelect),
86}
87
88#[derive(Clone, Copy, Debug)]
92pub(super) enum CachedPosition {
93 Unknown,
94 Known { seconds: f64 },
95}
96
97impl CachedPosition {
98 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
118pub(super) struct AtomicTrackId(AtomicU64);
125
126impl AtomicTrackId {
127 const NONE_BITS: u64 = u64::MAX;
128
129 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 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
195pub(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#[derive(Clone, Copy, Debug)]
226pub(super) enum Placement {
227 Append,
229 At(usize),
232}
233
234#[derive(Clone, Copy, Debug)]
238pub(crate) struct PlaybackTime {
239 pub(crate) dur: f64,
240 pub(crate) pos: f64,
241}
242
243pub(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 #[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 #[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}