1use std::fs::File;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
5use std::thread;
6
7use symphonia::core::audio::SampleBuffer;
8use symphonia::core::codecs::{
9 CODEC_TYPE_AAC, CODEC_TYPE_ALAC, CODEC_TYPE_FLAC, CODEC_TYPE_MP3, CODEC_TYPE_OPUS,
10 CODEC_TYPE_PCM_F32LE, CODEC_TYPE_PCM_S16LE, CODEC_TYPE_PCM_S24LE, CODEC_TYPE_PCM_S32LE,
11 CODEC_TYPE_VORBIS, CODEC_TYPE_WAVPACK, CodecType, DecoderOptions,
12};
13use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo};
14use symphonia::core::io::MediaSourceStream;
15use symphonia::core::meta::MetadataOptions;
16use symphonia::core::probe::Hint;
17use symphonia::core::units::Time;
18use thiserror::Error;
19
20use crate::audio::opus::OpusBridge;
21use crate::audio::viz::VizBuffer;
22use crate::config::ReplayGainMode;
23use crate::player::state::QueueItemId;
24
25#[derive(Debug, Error)]
26pub enum DecodeError {
27 #[error("failed to open file: {0}")]
28 Io(#[from] std::io::Error),
29 #[error("no supported audio track found")]
30 NoTrack,
31 #[error("unsupported codec")]
32 UnsupportedCodec,
33 #[error("decode error: {0}")]
34 Decode(String),
35}
36
37#[derive(Debug, Clone)]
39pub struct StreamInfo {
40 pub codec: String,
41 pub sample_rate: u32,
42 pub channels: u16,
43 pub bit_depth: Option<u16>,
44 pub duration_ms: u64,
45}
46
47pub struct DecodeHandle {
49 stop: Arc<AtomicBool>,
50 thread: Option<thread::JoinHandle<()>>,
51}
52
53impl DecodeHandle {
54 pub fn signal_stop(&self) {
56 self.stop.store(true, Ordering::Relaxed);
57 }
58
59 #[cfg(test)]
61 pub fn new_for_test(stop: Arc<AtomicBool>) -> Self {
62 Self { stop, thread: None }
63 }
64
65 pub fn stop(&mut self) {
67 self.signal_stop();
68 if let Some(handle) = self.thread.take()
69 && let Err(payload) = handle.join()
70 {
71 let msg = payload
72 .downcast_ref::<String>()
73 .map(|s| s.as_str())
74 .or_else(|| payload.downcast_ref::<&str>().copied())
75 .unwrap_or("unknown");
76 log::error!("decode thread panicked: {}", msg);
77 }
78 }
79}
80
81impl Drop for DecodeHandle {
82 fn drop(&mut self) {
83 self.stop();
84 }
85}
86
87#[derive(Debug, Clone)]
92pub struct TrackBoundary {
93 pub id: QueueItemId,
94 pub path: PathBuf,
95 pub info: StreamInfo,
96 pub sample_offset: u64,
100 pub samples_written: u64,
103 pub seek_samples: u64,
105}
106
107pub struct PlaybackTimeline {
111 boundaries: parking_lot::RwLock<Vec<TrackBoundary>>,
112 samples_written: AtomicU64,
114 pub samples_played: Arc<AtomicU64>,
117}
118
119impl PlaybackTimeline {
120 pub fn new() -> Arc<Self> {
121 Arc::new(Self {
122 boundaries: parking_lot::RwLock::new(Vec::new()),
123 samples_written: AtomicU64::new(0),
124 samples_played: Arc::new(AtomicU64::new(0)),
125 })
126 }
127
128 fn push_boundary(&self, boundary: TrackBoundary) {
130 self.boundaries.write().push(boundary);
131 }
132
133 fn add_written(&self, count: u64) {
135 self.samples_written.fetch_add(count, Ordering::Relaxed);
136 let mut bounds = self.boundaries.write();
138 if let Some(last) = bounds.last_mut() {
139 last.samples_written += count;
140 }
141 }
142
143 pub fn reset(&self) {
145 self.boundaries.write().clear();
146 self.samples_written.store(0, Ordering::Relaxed);
147 self.samples_played.store(0, Ordering::Relaxed);
148 }
149
150 pub fn samples_played_counter(&self) -> Arc<AtomicU64> {
152 self.samples_played.clone()
153 }
154
155 pub fn current_playback(&self) -> Option<(QueueItemId, PathBuf, StreamInfo, u64)> {
164 let bounds = self.boundaries.read();
166
167 if bounds.is_empty() {
168 return None;
169 }
170
171 let played = self.samples_played.load(Ordering::Acquire);
175
176 let idx = bounds.partition_point(|b| b.sample_offset <= played);
180 let current = if idx > 0 {
181 &bounds[idx - 1]
182 } else {
183 return None;
184 };
185
186 let ch = current.info.channels as u64;
187 let rate = current.info.sample_rate as u64;
188 if ch == 0 || rate == 0 {
189 return None;
190 }
191
192 let track_samples = played.saturating_sub(current.sample_offset);
195 let position_ms =
196 (track_samples / ch) * 1000 / rate + (current.seek_samples / ch) * 1000 / rate;
197
198 Some((
199 current.id,
200 current.path.clone(),
201 current.info.clone(),
202 position_ms,
203 ))
204 }
205}
206
207pub struct SourceEntry {
216 pub id: QueueItemId,
217 pub path: PathBuf,
219 pub hint: Hint,
221 pub make_mss: Box<dyn FnOnce() -> std::io::Result<MediaSourceStream> + Send>,
223}
224
225impl SourceEntry {
226 pub fn from_file(id: QueueItemId, path: PathBuf) -> Self {
228 let ext = path
229 .extension()
230 .and_then(|e| e.to_str())
231 .unwrap_or("")
232 .to_string();
233 let path_clone = path.clone();
234 let mut hint = Hint::new();
235 if !ext.is_empty() {
236 hint.with_extension(&ext);
237 }
238 Self {
239 id,
240 path,
241 hint,
242 make_mss: Box::new(move || {
243 let file = File::open(&path_clone)?;
244 Ok(MediaSourceStream::new(Box::new(file), Default::default()))
245 }),
246 }
247 }
248}
249
250pub fn probe_source(mss: MediaSourceStream, hint: &Hint) -> Result<StreamInfo, DecodeError> {
256 probe_mss(mss, hint)
257}
258
259pub fn probe_file(path: &Path) -> Result<StreamInfo, DecodeError> {
261 let file = File::open(path)?;
262 let mss = MediaSourceStream::new(Box::new(file), Default::default());
263 let mut hint = Hint::new();
264 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
265 hint.with_extension(ext);
266 }
267 probe_mss(mss, &hint)
268}
269
270fn probe_mss(mss: MediaSourceStream, hint: &Hint) -> Result<StreamInfo, DecodeError> {
272 let probed = symphonia::default::get_probe()
273 .format(
274 hint,
275 mss,
276 &FormatOptions::default(),
277 &MetadataOptions::default(),
278 )
279 .map_err(|e| DecodeError::Decode(e.to_string()))?;
280
281 let reader = probed.format;
282 let track = reader.default_track().ok_or(DecodeError::NoTrack)?;
283 let codec_params = &track.codec_params;
284 let is_opus = codec_params.codec == CODEC_TYPE_OPUS;
285 let sample_rate = if is_opus {
287 48000
288 } else {
289 codec_params.sample_rate.unwrap_or(44100)
290 };
291 let channels = codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
292 let bit_depth = if is_opus {
293 None
294 } else {
295 Some(codec_params.bits_per_sample.unwrap_or(16) as u16)
296 };
297 let duration_ms = track
298 .codec_params
299 .n_frames
300 .map(|frames| frames * 1000 / sample_rate as u64)
301 .unwrap_or(0);
302 let codec = codec_name(codec_params.codec);
303
304 Ok(StreamInfo {
305 codec,
306 sample_rate,
307 channels,
308 bit_depth,
309 duration_ms,
310 })
311}
312
313#[allow(clippy::too_many_arguments)]
324pub fn start_decode<N, F>(
325 first: SourceEntry,
326 producer: rtrb::Producer<f32>,
327 seek_ms: u64,
328 next_track: N,
329 timeline: Arc<PlaybackTimeline>,
330 viz_buffer: Option<Arc<VizBuffer>>,
331 rg_mode: ReplayGainMode,
332 pre_amp_db: f64,
333 on_finished: F,
334) -> Result<(StreamInfo, DecodeHandle), DecodeError>
335where
336 N: Fn() -> Option<SourceEntry> + Send + 'static,
337 F: FnOnce() + Send + 'static,
338{
339 let stop = Arc::new(AtomicBool::new(false));
340 let stop_clone = stop.clone();
341
342 let thread = thread::Builder::new()
343 .name("koan-decode".into())
344 .spawn(move || {
345 decode_queue_loop(
346 first,
347 producer,
348 &stop_clone,
349 seek_ms,
350 &next_track,
351 &timeline,
352 viz_buffer.as_deref(),
353 rg_mode,
354 pre_amp_db,
355 );
356 if !stop_clone.load(Ordering::Relaxed) {
360 on_finished();
361 }
362 })
363 .map_err(DecodeError::Io)?;
364
365 let placeholder = StreamInfo {
368 codec: String::from("?"),
369 sample_rate: 44100,
370 channels: 2,
371 bit_depth: Some(16),
372 duration_ms: 0,
373 };
374
375 Ok((
376 placeholder,
377 DecodeHandle {
378 stop,
379 thread: Some(thread),
380 },
381 ))
382}
383
384#[allow(clippy::too_many_arguments)]
394pub fn start_decode_file<N, F>(
395 initial_id: QueueItemId,
396 path: &Path,
397 producer: rtrb::Producer<f32>,
398 seek_ms: u64,
399 next_track: N,
400 timeline: Arc<PlaybackTimeline>,
401 viz_buffer: Option<Arc<VizBuffer>>,
402 rg_mode: ReplayGainMode,
403 pre_amp_db: f64,
404 on_finished: F,
405) -> Result<(StreamInfo, DecodeHandle), DecodeError>
406where
407 N: Fn() -> Option<(QueueItemId, PathBuf)> + Send + 'static,
408 F: FnOnce() + Send + 'static,
409{
410 let info = probe_file(path)?;
411 let first = SourceEntry::from_file(initial_id, path.to_path_buf());
412 let (_, handle) = start_decode(
413 first,
414 producer,
415 seek_ms,
416 move || {
417 let (id, p) = next_track()?;
418 Some(SourceEntry::from_file(id, p))
419 },
420 timeline,
421 viz_buffer,
422 rg_mode,
423 pre_amp_db,
424 on_finished,
425 )?;
426 Ok((info, handle))
427}
428
429#[allow(clippy::too_many_arguments)]
435fn decode_queue_loop<N>(
436 first: SourceEntry,
437 mut producer: rtrb::Producer<f32>,
438 stop: &AtomicBool,
439 initial_seek_ms: u64,
440 next_track: &N,
441 timeline: &PlaybackTimeline,
442 viz_buffer: Option<&VizBuffer>,
443 rg_mode: ReplayGainMode,
444 pre_amp_db: f64,
445) where
446 N: Fn() -> Option<SourceEntry>,
447{
448 let path = first.path.clone();
449 let hint = first.hint.clone();
450 let mss = match (first.make_mss)() {
451 Ok(mss) => mss,
452 Err(e) => {
453 if !stop.load(Ordering::Relaxed) {
454 log::error!("failed to open {}: {}", path.display(), e);
455 }
456 return;
457 }
458 };
459
460 if let Err(e) = decode_single(
461 first.id,
462 &path,
463 &hint,
464 mss,
465 &mut producer,
466 stop,
467 initial_seek_ms,
468 timeline,
469 viz_buffer,
470 rg_mode,
471 pre_amp_db,
472 ) {
473 if !stop.load(Ordering::Relaxed) {
474 log::error!("decode error on {}: {}", path.display(), e);
475 }
476 return;
477 }
478
479 while !stop.load(Ordering::Relaxed) {
480 let Some(entry) = (next_track)() else {
481 log::info!("playlist exhausted, decode thread finishing");
482 break;
483 };
484
485 log::info!("gapless transition → {}", entry.path.display());
486 let next_path = entry.path.clone();
487 let next_hint = entry.hint.clone();
488 let next_mss = match (entry.make_mss)() {
489 Ok(mss) => mss,
490 Err(e) => {
491 if !stop.load(Ordering::Relaxed) {
492 log::error!("failed to open {}: {}", next_path.display(), e);
493 }
494 break;
495 }
496 };
497
498 if let Err(e) = decode_single(
499 entry.id,
500 &next_path,
501 &next_hint,
502 next_mss,
503 &mut producer,
504 stop,
505 0,
506 timeline,
507 viz_buffer,
508 rg_mode,
509 pre_amp_db,
510 ) {
511 if !stop.load(Ordering::Relaxed) {
512 log::error!("decode error on {}: {}", next_path.display(), e);
513 }
514 break;
515 }
516 }
517}
518
519#[allow(clippy::too_many_arguments)]
525fn decode_single(
526 queue_item_id: QueueItemId,
527 path: &Path,
528 hint: &Hint,
529 mss: MediaSourceStream,
530 producer: &mut rtrb::Producer<f32>,
531 stop: &AtomicBool,
532 seek_ms: u64,
533 timeline: &PlaybackTimeline,
534 viz_buffer: Option<&VizBuffer>,
535 rg_mode: ReplayGainMode,
536 pre_amp_db: f64,
537) -> Result<(), DecodeError> {
538 let format_opts = FormatOptions {
539 enable_gapless: true,
540 ..Default::default()
541 };
542
543 let probed = symphonia::default::get_probe()
544 .format(hint, mss, &format_opts, &MetadataOptions::default())
545 .map_err(|e| DecodeError::Decode(e.to_string()))?;
546
547 let mut reader = probed.format;
548 let track = reader.default_track().ok_or(DecodeError::NoTrack)?;
549 let track_id = track.id;
550 let codec_params = &track.codec_params;
551 let is_opus_codec = codec_params.codec == CODEC_TYPE_OPUS;
552
553 let sample_rate = if is_opus_codec {
555 48000
556 } else {
557 codec_params.sample_rate.unwrap_or(44100)
558 };
559 let channels = codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
560
561 let info = StreamInfo {
562 codec: codec_name(codec_params.codec),
563 sample_rate,
564 channels,
565 bit_depth: if is_opus_codec {
566 None
567 } else {
568 Some(codec_params.bits_per_sample.unwrap_or(16) as u16)
569 },
570 duration_ms: codec_params
571 .n_frames
572 .map(|f| f * 1000 / sample_rate as u64)
573 .unwrap_or(0),
574 };
575
576 let seek_samples = seek_ms * sample_rate as u64 * channels as u64 / 1000;
577
578 let write_offset = timeline.samples_written.load(Ordering::Relaxed);
580 timeline.push_boundary(TrackBoundary {
581 id: queue_item_id,
582 path: path.to_path_buf(),
583 info,
584 sample_offset: write_offset,
585 samples_written: 0,
586 seek_samples,
587 });
588
589 let mut symphonia_decoder = if is_opus_codec {
591 None
592 } else {
593 Some(
594 symphonia::default::get_codecs()
595 .make(&track.codec_params, &DecoderOptions::default())
596 .map_err(|_| DecodeError::UnsupportedCodec)?,
597 )
598 };
599 let mut opus_bridge = if is_opus_codec {
600 Some(OpusBridge::new(codec_params).map_err(|e| DecodeError::Decode(e.to_string()))?)
601 } else {
602 None
603 };
604
605 if seek_ms > 0 {
607 let secs = seek_ms / 1000;
608 let frac = (seek_ms % 1000) as f64 / 1000.0;
609 reader
610 .seek(
611 SeekMode::Coarse,
612 SeekTo::Time {
613 time: Time::new(secs, frac),
614 track_id: Some(track_id),
615 },
616 )
617 .map_err(|e| DecodeError::Decode(format!("seek failed: {}", e)))?;
618 if let Some(ref mut dec) = symphonia_decoder {
619 dec.reset();
620 }
621 if let Some(ref mut opus) = opus_bridge {
622 opus.reset();
623 }
624 }
625
626 let rg_gain = if rg_mode != ReplayGainMode::Off {
628 match crate::audio::replaygain::read_tags(path) {
629 Ok(rg_info) => {
630 let selected = crate::audio::replaygain::select_gain(&rg_info, rg_mode);
631 if let Some((gain_db, _)) = selected {
632 log::info!(
633 "replaygain: applying {:.2} dB ({:?}) to {}",
634 gain_db,
635 rg_mode,
636 path.display()
637 );
638 }
639 selected
640 }
641 Err(e) => {
642 log::debug!("replaygain: no tags for {}: {}", path.display(), e);
643 None
644 }
645 }
646 } else {
647 None
648 };
649 let mut rg_scratch: Vec<f32> = Vec::new();
650
651 let mut sample_buf: Option<SampleBuffer<f32>> = None;
652
653 loop {
654 if stop.load(Ordering::Relaxed) {
655 return Ok(());
656 }
657
658 let packet = match reader.next_packet() {
659 Ok(p) => p,
660 Err(symphonia::core::errors::Error::IoError(ref e))
661 if e.kind() == std::io::ErrorKind::UnexpectedEof =>
662 {
663 return Ok(());
664 }
665 Err(e) => return Err(DecodeError::Decode(e.to_string())),
666 };
667
668 if packet.track_id() != track_id {
669 continue;
670 }
671
672 let samples: &[f32] = if let Some(ref mut opus) = opus_bridge {
674 match opus.decode_packet(packet.buf()) {
675 Ok(s) => s,
676 Err(e) => {
677 log::warn!("opus decode error (skipping packet): {}", e);
678 continue;
679 }
680 }
681 } else {
682 let decoder = symphonia_decoder.as_mut().unwrap();
683 let decoded = match decoder.decode(&packet) {
684 Ok(d) => d,
685 Err(symphonia::core::errors::Error::DecodeError(e)) => {
686 log::warn!("decode error (skipping packet): {}", e);
687 continue;
688 }
689 Err(e) => return Err(DecodeError::Decode(e.to_string())),
690 };
691
692 let spec = *decoded.spec();
693 let duration = decoded.capacity();
694 let sbuf = sample_buf.get_or_insert_with(|| SampleBuffer::new(duration as u64, spec));
695 sbuf.copy_interleaved_ref(decoded);
696 sbuf.samples()
697 };
698
699 if samples.is_empty() {
700 continue;
701 }
702
703 let samples = if let Some((gain_db, peak)) = rg_gain {
706 rg_scratch.clear();
707 rg_scratch.extend_from_slice(samples);
708 crate::audio::replaygain::apply_gain(&mut rg_scratch, gain_db, peak, pre_amp_db);
709 &rg_scratch[..]
710 } else {
711 samples
712 };
713
714 let mut offset = 0;
721 while offset < samples.len() {
722 if stop.load(Ordering::Relaxed) {
723 return Ok(());
724 }
725
726 let slots = producer.slots();
727 if slots == 0 {
728 thread::sleep(std::time::Duration::from_micros(500));
729 continue;
730 }
731
732 let chunk_size = slots.min(samples.len() - offset);
733 if let Ok(mut chunk) = producer.write_chunk_uninit(chunk_size) {
734 let to_write = &samples[offset..offset + chunk_size];
735 let (first, second) = chunk.as_mut_slices();
736 let first_len = first.len().min(to_write.len());
737 for (slot, &val) in first.iter_mut().zip(&to_write[..first_len]) {
738 slot.write(val);
739 }
740 if first_len < to_write.len() {
741 for (slot, &val) in second.iter_mut().zip(&to_write[first_len..]) {
742 slot.write(val);
743 }
744 }
745 unsafe { chunk.commit_all() };
749
750 if let Some(viz) = viz_buffer {
752 viz.push_samples(to_write, channels, sample_rate);
753 }
754
755 offset += chunk_size;
756 }
757 }
758
759 timeline.add_written(samples.len() as u64);
760 }
761}
762
763pub fn codec_name(codec: CodecType) -> String {
764 match codec {
765 CODEC_TYPE_FLAC => "FLAC",
766 CODEC_TYPE_MP3 => "MP3",
767 CODEC_TYPE_AAC => "AAC",
768 CODEC_TYPE_VORBIS => "Vorbis",
769 CODEC_TYPE_OPUS => "Opus",
770 CODEC_TYPE_ALAC => "ALAC",
771 CODEC_TYPE_WAVPACK => "WavPack",
772 CODEC_TYPE_PCM_S16LE => "PCM/16",
773 CODEC_TYPE_PCM_S24LE => "PCM/24",
774 CODEC_TYPE_PCM_S32LE => "PCM/32",
775 CODEC_TYPE_PCM_F32LE => "PCM/f32",
776 other => return format!("Unknown({:?})", other),
777 }
778 .to_string()
779}
780
781#[cfg(test)]
782mod tests {
783 use std::path::PathBuf;
784 use std::sync::atomic::Ordering;
785
786 use super::*;
787 use crate::player::state::QueueItemId;
788
789 fn make_info(sample_rate: u32, channels: u16) -> StreamInfo {
790 StreamInfo {
791 codec: "FLAC".to_string(),
792 sample_rate,
793 channels,
794 bit_depth: Some(16),
795 duration_ms: 10_000,
796 }
797 }
798
799 fn make_boundary(
800 id: QueueItemId,
801 sample_offset: u64,
802 seek_samples: u64,
803 channels: u16,
804 sample_rate: u32,
805 ) -> TrackBoundary {
806 TrackBoundary {
807 id,
808 path: PathBuf::from("/music/track.flac"),
809 info: make_info(sample_rate, channels),
810 sample_offset,
811 samples_written: 0,
812 seek_samples,
813 }
814 }
815
816 #[test]
819 fn test_timeline_single_track() {
820 let timeline = PlaybackTimeline::new();
824 let id = QueueItemId::new();
825 timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
827 timeline.add_written(88200); timeline.samples_played.store(88200, Ordering::Relaxed);
831
832 let result = timeline.current_playback();
833 assert!(
834 result.is_some(),
835 "expected Some for single track with samples played"
836 );
837 let (result_id, _path, _info, position_ms) = result.unwrap();
838 assert_eq!(result_id, id);
839 assert_eq!(
840 position_ms, 1000,
841 "1 second of 44100 Hz stereo should be 1000 ms"
842 );
843 }
844
845 #[test]
846 fn test_timeline_gapless_transition() {
847 let timeline = PlaybackTimeline::new();
851 let id1 = QueueItemId::new();
852 let id2 = QueueItemId::new();
853
854 timeline.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
856 timeline.add_written(88200);
857
858 timeline.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
860 timeline.add_written(44100); timeline.samples_played.store(90000, Ordering::Relaxed);
864
865 let result = timeline.current_playback();
866 assert!(result.is_some());
867 let (result_id, _path, _info, position_ms) = result.unwrap();
868 assert_eq!(
869 result_id, id2,
870 "playback head past boundary should report second track"
871 );
872 assert_eq!(position_ms, 20, "position within track 2 should be ~20 ms");
874 }
875
876 #[test]
877 fn test_timeline_zero_samples() {
878 let timeline = PlaybackTimeline::new();
881 let id = QueueItemId::new();
882 timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
883 timeline.add_written(1000);
884 timeline.samples_played.store(0, Ordering::Relaxed);
885
886 let result = timeline.current_playback();
887 assert!(
888 result.is_some(),
889 "expected Some at 0 samples played with a boundary at offset 0"
890 );
891 let (result_id, _path, _info, position_ms) = result.unwrap();
892 assert_eq!(result_id, id);
893 assert_eq!(position_ms, 0);
894 }
895
896 #[test]
897 fn test_timeline_past_all_boundaries() {
898 let timeline = PlaybackTimeline::new();
901 let id1 = QueueItemId::new();
902 let id2 = QueueItemId::new();
903
904 timeline.push_boundary(make_boundary(id1, 0, 0, 2, 44100));
905 timeline.add_written(88200);
906 timeline.push_boundary(make_boundary(id2, 88200, 0, 2, 44100));
907 timeline.add_written(88200);
908
909 timeline
911 .samples_played
912 .store(999_999_999, Ordering::Relaxed);
913
914 let result = timeline.current_playback();
915 assert!(result.is_some());
916 let (result_id, _path, _info, _position_ms) = result.unwrap();
917 assert_eq!(
918 result_id, id2,
919 "samples past all boundaries should report the last track"
920 );
921 }
922
923 #[test]
924 fn test_timeline_seek_offset() {
925 let timeline = PlaybackTimeline::new();
929 let id = QueueItemId::new();
930 let seek_samples = 88200u64; timeline.push_boundary(make_boundary(id, 0, seek_samples, 2, 44100));
932 timeline.add_written(44100); timeline.samples_played.store(0, Ordering::Relaxed);
935
936 let result = timeline.current_playback();
937 assert!(result.is_some());
938 let (_result_id, _path, _info, position_ms) = result.unwrap();
939 assert_eq!(
941 position_ms, 1000,
942 "position should include seek offset of 1000 ms"
943 );
944 }
945
946 #[test]
947 fn test_timeline_reset() {
948 let timeline = PlaybackTimeline::new();
950 let id = QueueItemId::new();
951 timeline.push_boundary(make_boundary(id, 0, 0, 2, 44100));
952 timeline.add_written(88200);
953 timeline.samples_played.store(44100, Ordering::Relaxed);
954
955 assert!(timeline.current_playback().is_some());
957
958 timeline.reset();
959
960 assert!(
961 timeline.current_playback().is_none(),
962 "after reset, current_playback should return None"
963 );
964 assert_eq!(
965 timeline.samples_played.load(Ordering::Relaxed),
966 0,
967 "samples_played should be 0 after reset"
968 );
969 assert_eq!(
970 timeline.samples_written.load(Ordering::Relaxed),
971 0,
972 "samples_written should be 0 after reset"
973 );
974 }
975}