1pub mod commands;
2pub mod state;
3pub mod undo;
4
5use std::path::Path;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::thread;
9
10use thiserror::Error;
11
12use crate::audio::{
13 analyzer::VizAnalyzer,
14 backend::{self, AudioBackend, AudioEngineHandle, BackendError},
15 buffer, streaming,
16 viz::{VizBuffer, VizSnapshot},
17};
18use buffer::PlaybackTimeline;
19use commands::{CommandChannel, PlayerCommand};
20use state::{LoadState, PlaybackSource, PlaybackState, QueueItemId, SharedPlayerState, TrackInfo};
21use undo::{UndoEntry, UndoStack};
22
23const RING_BUFFER_SIZE: usize = 192_000 * 2;
25
26#[derive(Debug, Error)]
27pub enum PlayerError {
28 #[error("backend error: {0}")]
29 Backend(#[from] BackendError),
30 #[error("decode error: {0}")]
31 Decode(#[from] buffer::DecodeError),
32}
33
34pub struct Player {
36 shared_state: Arc<SharedPlayerState>,
37 commands: CommandChannel,
38 active_playback: Option<ActivePlayback>,
39 timeline: Arc<PlaybackTimeline>,
40 viz_buffer: Arc<VizBuffer>,
41 viz_snapshot: Arc<VizSnapshot>,
42 _viz_analyzer: VizAnalyzer,
44 undo_stack: UndoStack,
45 batch_buffer: Option<Vec<UndoEntry>>,
48 output_device_name: Option<String>,
50 backend: Box<dyn AudioBackend>,
52 last_skip: std::time::Instant,
54}
55
56struct ActivePlayback {
58 engine: Box<dyn AudioEngineHandle>,
59 decode_handle: buffer::DecodeHandle,
60}
61
62impl Default for Player {
63 fn default() -> Self {
64 Self::new()
65 }
66}
67
68impl Player {
69 pub fn new() -> Self {
70 let viz_buffer = VizBuffer::new();
71 let viz_snapshot = VizSnapshot::new();
72 let cfg = crate::config::Config::load_or_default();
73 let viz_analyzer = VizAnalyzer::spawn_with_snapshot(
74 Arc::clone(&viz_buffer),
75 &cfg.visualizer,
76 Arc::clone(&viz_snapshot),
77 );
78
79 Self {
80 shared_state: SharedPlayerState::new(),
81 commands: CommandChannel::new(),
82 active_playback: None,
83 timeline: PlaybackTimeline::new(),
84 viz_buffer,
85 viz_snapshot,
86 _viz_analyzer: viz_analyzer,
87 undo_stack: UndoStack::new(),
88 batch_buffer: None,
89 output_device_name: cfg.playback.output_device.clone(),
90 backend: crate::audio::platform_backend(),
91 last_skip: std::time::Instant::now(),
92 }
93 }
94
95 pub fn shared_state(&self) -> Arc<SharedPlayerState> {
97 self.shared_state.clone()
98 }
99
100 pub fn timeline(&self) -> Arc<PlaybackTimeline> {
102 self.timeline.clone()
103 }
104
105 pub fn viz_buffer(&self) -> Arc<VizBuffer> {
107 self.viz_buffer.clone()
108 }
109
110 pub fn viz_snapshot(&self) -> Arc<VizSnapshot> {
113 self.viz_snapshot.clone()
114 }
115
116 pub fn undo_stack(&self) -> &UndoStack {
118 &self.undo_stack
119 }
120
121 fn resolve_device(&self) -> Result<backend::DeviceInfo, PlayerError> {
124 if let Some(ref name) = self.output_device_name {
125 match self.backend.list_devices() {
126 Ok(devices) => {
127 if let Some(dev) = devices.into_iter().find(|d| d.name == *name) {
128 return Ok(dev);
129 }
130 log::warn!(
131 "configured output device '{}' not found, falling back to default",
132 name,
133 );
134 }
135 Err(e) => {
136 log::warn!("failed to list devices while resolving '{}': {}", name, e);
137 }
138 }
139 }
140 Ok(self.backend.default_device()?)
141 }
142
143 pub fn set_output_device(&mut self, name: String) {
146 log::info!("switching output device to: {}", name);
147 self.output_device_name = Some(name.clone());
148
149 if let Err(e) = crate::config::Config::update_base(|cfg| {
151 cfg.playback.output_device = Some(name);
152 }) {
153 log::error!("failed to save output device config: {}", e);
154 }
155
156 self.restart_on_current_track();
157 }
158
159 pub fn clear_output_device(&mut self) {
161 log::info!("reverting to system default output device");
162 self.output_device_name = None;
163
164 if let Err(e) = crate::config::Config::update_base(|cfg| {
165 cfg.playback.output_device = None;
166 }) {
167 log::error!("failed to save output device config: {}", e);
168 }
169
170 self.restart_on_current_track();
171 }
172
173 fn restart_on_current_track(&mut self) {
176 if let Some(info) = self.shared_state.track_info() {
177 let was_paused = self.shared_state.playback_state() == PlaybackState::Paused;
178 let position_ms = self.shared_state.position_ms();
179 if let Err(e) = self.start_playback(info.id, &info.path, position_ms) {
180 log::error!("failed to restart playback on device switch: {}", e);
181 return;
182 }
183 if was_paused {
184 self.pause();
185 }
186 }
187 }
188
189 pub fn output_device_name(&self) -> Option<&str> {
191 self.output_device_name.as_deref()
192 }
193
194 pub fn command_sender(&self) -> crossbeam_channel::Sender<PlayerCommand> {
196 self.commands.tx.clone()
197 }
198
199 pub fn play(&mut self, id: QueueItemId) {
202 self.shared_state.set_cursor(Some(id));
203
204 match self.shared_state.item_playback_source(id) {
205 Some(PlaybackSource::Ready(path)) => {
206 if let Err(e) = self.start_playback(id, &path, 0) {
207 log::error!("play failed: {}", e);
208 }
209 }
210 Some(PlaybackSource::Streaming {
211 path,
212 bytes_written,
213 total,
214 }) => {
215 if let Err(e) = self.start_streaming_playback(id, &path, bytes_written, total) {
216 log::error!("streaming play failed, waiting for full download: {}", e);
217 self.stop_engine();
219 self.shared_state.set_playback_state(PlaybackState::Stopped);
220 }
221 }
222 None => {
223 self.stop_engine();
225 self.shared_state.set_playback_state(PlaybackState::Stopped);
226 log::info!("play: item {:?} not ready, waiting for TrackReady", id);
227 }
228 }
229 }
230
231 fn start_playback(
233 &mut self,
234 id: QueueItemId,
235 path: &Path,
236 seek_ms: u64,
237 ) -> Result<(), PlayerError> {
238 self.stop_engine();
239
240 let info = buffer::probe_file(path)?;
241
242 self.shared_state.set_track_info(Some(TrackInfo {
246 id,
247 path: path.to_path_buf(),
248 codec: info.codec.clone(),
249 sample_rate: info.sample_rate,
250 bit_depth: info.bit_depth,
251 channels: info.channels,
252 duration_ms: info.duration_ms,
253 }));
254 self.shared_state.set_position_ms(seek_ms);
255 log::info!(
256 "playing: {} ({:?}) — {} {}Hz/{}bit/{}ch, {}ms{}",
257 path.display(),
258 id,
259 info.codec,
260 info.sample_rate,
261 info.bit_depth,
262 info.channels,
263 info.duration_ms,
264 if seek_ms > 0 {
265 format!(" @{}ms", seek_ms)
266 } else {
267 String::new()
268 }
269 );
270
271 let device = self.resolve_device()?;
272 let device_rate = self.backend.get_device_sample_rate(&device)?;
273 let source_rate = info.sample_rate as f64;
274
275 if (device_rate - source_rate).abs() > 0.1 {
276 log::info!(
277 "switching device sample rate: {}Hz → {}Hz",
278 device_rate,
279 source_rate
280 );
281 if let Err(e) = self.backend.set_device_sample_rate(&device, source_rate) {
282 log::warn!(
283 "failed to set sample rate (continuing at device rate): {}",
284 e
285 );
286 }
287 }
288
289 let (producer, consumer) = rtrb::RingBuffer::new(RING_BUFFER_SIZE);
290
291 let _generation = self.shared_state.bump_generation();
292
293 self.timeline.reset();
295
296 let advance_state = self.shared_state.clone();
300 let decode_cursor = std::sync::Mutex::new(Some(id));
301 let next_track = move || {
302 let current = decode_cursor.lock().ok()?.take()?;
303 let next = advance_state.peek_next_ready_after(current);
304 if let Some((next_id, _)) = &next
305 && let Ok(mut guard) = decode_cursor.lock()
306 {
307 *guard = Some(*next_id);
308 }
309 next
310 };
311
312 let cfg = crate::config::Config::load_or_default();
314 let rg_mode = cfg.playback.replaygain;
315 let pre_amp_db = cfg.playback.pre_amp_db;
316
317 let finish_tx = self.commands.tx.clone();
318 let (_stream_info, decode_handle) = buffer::start_decode_file(
319 id,
320 path,
321 producer,
322 seek_ms,
323 next_track,
324 self.timeline.clone(),
325 Some(self.viz_buffer.clone()),
326 rg_mode,
327 pre_amp_db,
328 move || {
329 finish_tx.send(PlayerCommand::DecodeFinished).ok();
330 },
331 )?;
332
333 let actual_rate = self
335 .backend
336 .get_device_sample_rate(&device)
337 .unwrap_or(source_rate);
338 let engine = self.backend.create_engine(
339 &device,
340 actual_rate,
341 info.channels as u32,
342 consumer,
343 self.timeline.samples_played_counter(),
344 )?;
345 engine.start()?;
346
347 self.shared_state.set_playback_state(PlaybackState::Playing);
348
349 self.active_playback = Some(ActivePlayback {
350 engine,
351 decode_handle,
352 });
353
354 Ok(())
355 }
356
357 fn start_streaming_playback(
364 &mut self,
365 id: QueueItemId,
366 path: &Path,
367 bytes_written: Arc<AtomicU64>,
368 total: u64,
369 ) -> Result<(), PlayerError> {
370 self.stop_engine();
371
372 let stream_buf = streaming::StreamBuffer::new(if total > 0 { Some(total) } else { None });
374
375 let pump_path = path.to_path_buf();
379 let pump_buf = stream_buf.clone();
380 let pump_written = bytes_written.clone();
381 thread::Builder::new()
382 .name("koan-stream-pump".into())
383 .spawn(move || {
384 use std::fs::File;
385 use std::io::Read;
386 let mut file = match File::open(&pump_path) {
387 Ok(f) => f,
388 Err(e) => {
389 log::error!("stream pump: failed to open {}: {}", pump_path.display(), e);
390 pump_buf.finish();
391 return;
392 }
393 };
394 let mut buf = vec![0u8; 65536];
395 let mut offset: u64 = 0;
396 loop {
397 let available = pump_written.load(Ordering::Acquire);
398 if offset >= available {
399 if total > 0 && available >= total {
400 break; }
402 thread::sleep(std::time::Duration::from_millis(10));
403 continue;
404 }
405 let to_read = ((available - offset) as usize).min(buf.len());
406 match file.read(&mut buf[..to_read]) {
407 Ok(0) => {
408 if total > 0 && offset >= total {
411 break;
412 }
413 let latest = pump_written.load(Ordering::Acquire);
414 if total > 0 && latest >= total && offset >= latest {
415 break;
416 }
417 thread::sleep(std::time::Duration::from_millis(1));
419 continue;
420 }
421 Ok(n) => {
422 pump_buf.push(&buf[..n]);
423 offset += n as u64;
424 }
425 Err(e) => {
426 log::warn!("stream pump read error: {}", e);
427 break;
428 }
429 }
430 }
431 pump_buf.finish();
432 })
433 .map_err(|e| PlayerError::Decode(buffer::DecodeError::Io(e)))?;
434
435 let probe_reader = stream_buf.reader();
437 let probe_hint = {
438 let mut h = symphonia::core::probe::Hint::new();
439 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
440 h.with_extension(ext);
441 }
442 h
443 };
444 let probe_mss =
445 symphonia::core::io::MediaSourceStream::new(Box::new(probe_reader), Default::default());
446 let info = buffer::probe_source(probe_mss, &probe_hint)?;
447
448 self.shared_state.set_track_info(Some(TrackInfo {
449 id,
450 path: path.to_path_buf(),
451 codec: info.codec.clone(),
452 sample_rate: info.sample_rate,
453 bit_depth: info.bit_depth,
454 channels: info.channels,
455 duration_ms: info.duration_ms,
456 }));
457 self.shared_state.set_position_ms(0);
458 log::info!(
459 "streaming: {} ({:?}) — {} {}Hz/{}bit/{}ch, {}ms",
460 path.display(),
461 id,
462 info.codec,
463 info.sample_rate,
464 info.bit_depth,
465 info.channels,
466 info.duration_ms,
467 );
468
469 let device = self.resolve_device()?;
470 let device_rate = self.backend.get_device_sample_rate(&device)?;
471 let source_rate = info.sample_rate as f64;
472
473 if (device_rate - source_rate).abs() > 0.1 {
474 log::info!(
475 "switching device sample rate: {}Hz → {}Hz",
476 device_rate,
477 source_rate
478 );
479 if let Err(e) = self.backend.set_device_sample_rate(&device, source_rate) {
480 log::warn!(
481 "failed to set sample rate (continuing at device rate): {}",
482 e
483 );
484 }
485 }
486
487 let (producer, consumer) = rtrb::RingBuffer::new(RING_BUFFER_SIZE);
488
489 let _generation = self.shared_state.bump_generation();
490 self.timeline.reset();
491
492 let advance_state = self.shared_state.clone();
494 let decode_cursor = std::sync::Mutex::new(Some(id));
495 let next_track = move || {
496 let current = decode_cursor.lock().ok()?.take()?;
497 let next = advance_state.peek_next_ready_after(current);
498 if let Some((next_id, _)) = &next
499 && let Ok(mut guard) = decode_cursor.lock()
500 {
501 *guard = Some(*next_id);
502 }
503 next
504 };
505
506 let decode_reader = stream_buf.reader();
511 let path_buf = path.to_path_buf();
512 let ext = path
513 .extension()
514 .and_then(|e| e.to_str())
515 .unwrap_or("")
516 .to_string();
517 let mut decode_hint = symphonia::core::probe::Hint::new();
518 if !ext.is_empty() {
519 decode_hint.with_extension(&ext);
520 }
521 let first = buffer::SourceEntry {
522 id,
523 path: path_buf,
524 hint: decode_hint,
525 make_mss: Box::new(move || {
526 Ok(symphonia::core::io::MediaSourceStream::new(
527 Box::new(decode_reader),
528 Default::default(),
529 ))
530 }),
531 };
532
533 let cfg = crate::config::Config::load_or_default();
535 let rg_mode = cfg.playback.replaygain;
536 let pre_amp_db = cfg.playback.pre_amp_db;
537
538 let finish_tx = self.commands.tx.clone();
539 let (_stream_info, decode_handle) = buffer::start_decode(
540 first,
541 producer,
542 0,
543 move || {
544 let (next_id, next_path) = next_track()?;
545 Some(buffer::SourceEntry::from_file(next_id, next_path))
546 },
547 self.timeline.clone(),
548 Some(self.viz_buffer.clone()),
549 rg_mode,
550 pre_amp_db,
551 move || {
552 finish_tx.send(PlayerCommand::DecodeFinished).ok();
553 },
554 )?;
555
556 let actual_rate = self
557 .backend
558 .get_device_sample_rate(&device)
559 .unwrap_or(source_rate);
560 let engine = self.backend.create_engine(
561 &device,
562 actual_rate,
563 info.channels as u32,
564 consumer,
565 self.timeline.samples_played_counter(),
566 )?;
567 engine.start()?;
568
569 self.shared_state.set_playback_state(PlaybackState::Playing);
570
571 self.active_playback = Some(ActivePlayback {
572 engine,
573 decode_handle,
574 });
575
576 Ok(())
577 }
578
579 pub fn seek(&mut self, position_ms: u64) {
582 let info = match self.shared_state.track_info() {
583 Some(info) => info,
584 None => return,
585 };
586 let id = info.id;
587 let path = info.path.clone();
588 let duration = info.duration_ms;
589
590 let mut clamped = if duration > 0 {
592 position_ms.min(duration.saturating_sub(500))
593 } else {
594 position_ms
595 };
596
597 if let Some(dl_frac) = self.shared_state.current_download_fraction() {
600 let max_ms = (dl_frac * duration as f64) as u64;
601 if max_ms > 5_000 {
602 clamped = clamped.min(max_ms - 5_000);
603 }
604 }
605
606 let was_paused = self.shared_state.playback_state() == PlaybackState::Paused;
607
608 if let Err(e) = self.start_playback(id, &path, clamped) {
609 log::error!("seek failed: {}", e);
610 return;
611 }
612
613 if was_paused {
614 self.pause();
615 }
616 }
617
618 pub fn next_track(&mut self) {
620 match self.shared_state.advance_cursor() {
622 Some((id, path)) => {
623 if let Err(e) = self.start_playback(id, &path, 0) {
624 log::error!("next track failed: {}", e);
625 }
626 }
627 None => {
628 log::info!("no more tracks in playlist");
629 self.stop_playback_and_clear_state();
630 }
631 }
632 }
633
634 pub fn prev_track(&mut self) {
636 match self.shared_state.retreat_cursor() {
637 Some((id, path)) => {
638 if matches!(path.try_exists(), Ok(true)) {
639 if let Err(e) = self.start_playback(id, &path, 0) {
640 log::error!("prev track failed: {}", e);
641 }
642 } else {
643 log::warn!("prev track path doesn't exist: {}", path.display());
644 }
645 }
646 None => {
647 if let Some(info) = self.shared_state.track_info()
649 && let Err(e) = self.start_playback(info.id, &info.path, 0)
650 {
651 log::error!("restart failed: {}", e);
652 }
653 }
654 }
655 }
656
657 pub fn pause(&mut self) {
659 if let Some(ref playback) = self.active_playback {
660 let _ = playback.engine.stop();
661 self.shared_state.set_playback_state(PlaybackState::Paused);
662 }
663 }
664
665 pub fn resume(&mut self) {
667 if let Some(ref playback) = self.active_playback {
668 let _ = playback.engine.start();
669 self.shared_state.set_playback_state(PlaybackState::Playing);
670 }
671 }
672
673 pub fn stop(&mut self) {
675 self.shared_state.clear_playlist();
676 self.stop_playback_and_clear_state();
677 }
678
679 fn stop_engine(&mut self) {
686 if let Some(playback) = self.active_playback.take() {
687 let _ = playback.engine.stop();
689 playback.decode_handle.signal_stop();
691
692 let ActivePlayback {
697 engine,
698 decode_handle,
699 } = playback;
700 drop(engine);
701
702 thread::Builder::new()
706 .name("koan-cleanup".into())
707 .spawn(move || drop(decode_handle))
708 .ok();
709 }
710 }
711
712 fn stop_playback_and_clear_state(&mut self) {
714 self.stop_engine();
715 self.timeline.reset();
716 self.shared_state.set_playback_state(PlaybackState::Stopped);
717 self.shared_state.set_position_ms(0);
718 self.shared_state.set_track_info(None);
719 }
720
721 pub fn remove_from_playlist(&mut self, id: QueueItemId) {
723 let was_cursor = self.shared_state.is_cursor(id);
724 self.shared_state.remove_item(id);
725 if was_cursor {
726 self.next_track();
727 }
728 }
729
730 pub fn track_ready(&mut self, id: QueueItemId) {
733 self.shared_state.update_load_state(id, LoadState::Ready);
735
736 if !self.shared_state.is_cursor(id) {
737 return;
738 }
739
740 let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
741 let current_track_id = self.shared_state.track_info().map(|t| t.id);
742
743 if is_playing && current_track_id == Some(id) {
744 log::info!(
747 "track_ready: download complete while streaming {:?}, refreshing metadata",
748 id
749 );
750 self.refresh_track_metadata(id);
751 return;
752 }
753
754 if !is_playing && let Some(path) = self.shared_state.item_path_if_ready(id) {
756 log::info!("track_ready: starting playback for {:?}", id);
757 if let Err(e) = self.start_playback(id, &path, 0) {
758 log::error!("track_ready playback failed: {}", e);
759 }
760 }
761 }
762
763 pub fn track_stream_ready(&mut self, id: QueueItemId) {
766 if !self.shared_state.is_cursor(id) {
767 return;
768 }
769
770 let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
771 if is_playing {
772 return; }
774
775 match self.shared_state.item_playback_source(id) {
776 Some(PlaybackSource::Streaming {
777 path,
778 bytes_written,
779 total,
780 }) => {
781 log::info!(
782 "track_stream_ready: starting streaming playback for {:?}",
783 id
784 );
785 if let Err(e) = self.start_streaming_playback(id, &path, bytes_written, total) {
786 log::error!("track_stream_ready streaming failed: {}", e);
787 }
788 }
789 Some(PlaybackSource::Ready(path)) => {
790 log::info!(
792 "track_stream_ready: track already ready, starting normal playback for {:?}",
793 id
794 );
795 if let Err(e) = self.start_playback(id, &path, 0) {
796 log::error!("track_stream_ready playback failed: {}", e);
797 }
798 }
799 None => {} }
801 }
802
803 fn refresh_track_metadata(&mut self, id: QueueItemId) {
807 use crate::index::metadata;
808
809 let path = match self.shared_state.item_path_if_ready(id) {
810 Some(p) => p,
811 None => return,
812 };
813
814 match metadata::read_metadata(&path) {
815 Ok(meta) => {
816 self.shared_state.update_item_metadata(
818 id,
819 meta.title,
820 meta.artist,
821 meta.album_artist.unwrap_or_default(),
822 meta.album,
823 meta.duration_ms.map(|d| d as u64),
824 );
825
826 if let Ok(stream_info) = buffer::probe_file(&path)
831 && let Some(current) = self.shared_state.track_info()
832 && current.id == id
833 && stream_info.duration_ms > current.duration_ms
834 {
835 log::info!(
836 "track_ready: duration corrected {}ms → {}ms",
837 current.duration_ms,
838 stream_info.duration_ms
839 );
840 self.shared_state.set_track_info(Some(TrackInfo {
841 duration_ms: stream_info.duration_ms,
842 ..current
843 }));
844 }
845
846 self.shared_state.signal_metadata_refresh();
848 log::info!("track_ready: metadata refreshed for {:?}", id);
849 }
850 Err(e) => {
851 log::warn!("track_ready: metadata refresh failed for {:?}: {}", id, e);
852 }
853 }
854 }
855
856 pub fn update_playback_state(&self) {
859 if self.active_playback.is_none() {
860 return;
861 }
862
863 if let Some((id, path, info, position_ms)) = self.timeline.current_playback() {
864 self.shared_state.set_position_ms(position_ms);
865
866 let current_id = self.shared_state.track_info().map(|t| t.id);
869 if current_id != Some(id) {
870 log::info!("timeline: now playing {:?}", id);
871 self.shared_state.set_track_info(Some(TrackInfo {
872 id,
873 path,
874 codec: info.codec,
875 sample_rate: info.sample_rate,
876 bit_depth: info.bit_depth,
877 channels: info.channels,
878 duration_ms: info.duration_ms,
879 }));
880 self.shared_state.set_cursor(Some(id));
881 }
882 }
883 }
884
885 fn on_decode_finished(&mut self) {
888 log::info!("decode finished, checking for next track");
889 match self.shared_state.advance_cursor() {
890 Some((id, path)) => {
891 if let Err(e) = self.start_playback(id, &path, 0) {
892 log::error!("auto-advance after decode finished failed: {}", e);
893 self.stop_playback_and_clear_state();
894 }
895 }
896 None => {
897 log::info!("no more tracks — stopping");
898 self.stop_playback_and_clear_state();
899 }
900 }
901 }
902
903 fn push_undo(&mut self, entry: UndoEntry) {
905 if let Some(ref mut batch) = self.batch_buffer {
906 batch.push(entry);
907 } else {
908 self.undo_stack.push(entry);
909 }
910 }
911
912 pub fn process_command(&mut self, cmd: PlayerCommand) {
914 match cmd {
915 PlayerCommand::Play(id) => self.play(id),
916 PlayerCommand::Pause => self.pause(),
917 PlayerCommand::Resume => self.resume(),
918 PlayerCommand::Stop => self.stop(),
919 PlayerCommand::Seek(pos) => self.seek(pos),
920 PlayerCommand::NextTrack => {
921 let now = std::time::Instant::now();
923 if now.duration_since(self.last_skip).as_millis() >= 150 {
924 self.last_skip = now;
925 self.next_track();
926 }
927 }
928 PlayerCommand::PrevTrack => {
929 let now = std::time::Instant::now();
930 if now.duration_since(self.last_skip).as_millis() >= 150 {
931 self.last_skip = now;
932 self.prev_track();
933 }
934 }
935 PlayerCommand::AddToPlaylist(items) => {
936 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
937 self.shared_state.add_items(items);
938 self.push_undo(UndoEntry::Added { ids });
939 }
940 PlayerCommand::UpdatePaths(updates) => {
941 self.shared_state.update_paths(&updates);
942 if let Some(info) = self.shared_state.track_info()
943 && let Some((_, new_path)) = updates.iter().find(|(id, _)| *id == info.id)
944 {
945 self.shared_state.set_track_info(Some(TrackInfo {
946 path: new_path.clone(),
947 ..info
948 }));
949 }
950 }
951 PlayerCommand::InsertInPlaylist { items, after } => {
952 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
953 self.shared_state.insert_items_after(items, after);
954 self.push_undo(UndoEntry::Inserted { ids });
955 }
956 PlayerCommand::ClearPlaylist => {
957 self.stop_playback_and_clear_state();
961 let (items, cursor) = self.shared_state.snapshot_playlist();
962 self.shared_state.clear_playlist();
963 self.push_undo(UndoEntry::Replaced { items, cursor });
964 }
965 PlayerCommand::RemoveFromPlaylist(id) => {
966 let item = self.shared_state.get_item(id);
967 let after = self.shared_state.item_before(id);
968 self.remove_from_playlist(id);
969 if let Some(item) = item {
970 self.push_undo(UndoEntry::Removed {
971 items: vec![(Box::new(item), after)],
972 });
973 }
974 }
975 PlayerCommand::RemoveFromPlaylistBatch(ids) => {
976 let items_with_pos: Vec<_> = ids
978 .iter()
979 .filter_map(|&id| {
980 let item = self.shared_state.get_item(id)?;
981 let after = self.shared_state.item_before(id);
982 Some((Box::new(item), after))
983 })
984 .collect();
985 for &id in &ids {
986 self.remove_from_playlist(id);
987 }
988 if !items_with_pos.is_empty() {
989 self.push_undo(UndoEntry::Removed {
990 items: items_with_pos,
991 });
992 }
993 }
994 PlayerCommand::MoveInPlaylist { id, target, after } => {
995 let was_after = self.shared_state.item_before(id);
996 self.shared_state.move_item(id, target, after);
997 self.push_undo(UndoEntry::Moved { id, was_after });
998 }
999 PlayerCommand::MoveItemsInPlaylist { ids, target, after } => {
1000 let entries = self.shared_state.items_before(&ids);
1001 self.shared_state.move_items(&ids, target, after);
1002 self.push_undo(UndoEntry::MovedBatch { entries });
1003 }
1004 PlayerCommand::TrackReady(id) => self.track_ready(id),
1005 PlayerCommand::DecodeFinished => self.on_decode_finished(),
1006 PlayerCommand::TrackStreamReady(id) => self.track_stream_ready(id),
1007 PlayerCommand::Undo => self.execute_undo(),
1008 PlayerCommand::Redo => self.execute_redo(),
1009 PlayerCommand::BeginUndoBatch => {
1010 self.batch_buffer = Some(Vec::new());
1011 }
1012 PlayerCommand::EndUndoBatch => {
1013 if let Some(entries) = self.batch_buffer.take() {
1014 if entries.len() == 1 {
1015 self.undo_stack.push(entries.into_iter().next().unwrap());
1017 } else if !entries.is_empty() {
1018 self.undo_stack.push(UndoEntry::Batch(entries));
1019 }
1020 }
1021 }
1022 PlayerCommand::SetOutputDevice(name) => self.set_output_device(name),
1023 PlayerCommand::ClearOutputDevice => self.clear_output_device(),
1024 }
1025 }
1026
1027 fn apply_entry(&mut self, entry: UndoEntry) -> Option<UndoEntry> {
1029 match entry {
1030 UndoEntry::Added { ids } => {
1031 let items_with_pos: Vec<_> = ids
1033 .iter()
1034 .filter_map(|&id| {
1035 let item = self.shared_state.get_item(id)?;
1036 let after = self.shared_state.item_before(id);
1037 Some((Box::new(item), after))
1038 })
1039 .collect();
1040 self.shared_state.remove_items(&ids);
1041 Some(UndoEntry::Removed {
1042 items: items_with_pos,
1043 })
1044 }
1045 UndoEntry::Removed { items } => {
1046 let mut ids = Vec::with_capacity(items.len());
1048 for (item, after) in items {
1049 ids.push(item.id);
1050 self.shared_state.insert_item_at(*item, after);
1051 }
1052 Some(UndoEntry::Added { ids })
1053 }
1054 UndoEntry::Inserted { ids } => {
1055 let items_with_pos: Vec<_> = ids
1057 .iter()
1058 .filter_map(|&id| {
1059 let item = self.shared_state.get_item(id)?;
1060 let after = self.shared_state.item_before(id);
1061 Some((Box::new(item), after))
1062 })
1063 .collect();
1064 self.shared_state.remove_items(&ids);
1065 Some(UndoEntry::Removed {
1066 items: items_with_pos,
1067 })
1068 }
1069 UndoEntry::Moved { id, was_after } => {
1070 let current_after = self.shared_state.item_before(id);
1071 self.shared_state.move_item_to(id, was_after);
1072 Some(UndoEntry::Moved {
1073 id,
1074 was_after: current_after,
1075 })
1076 }
1077 UndoEntry::MovedBatch { entries } => {
1078 let ids: Vec<QueueItemId> = entries.iter().map(|(id, _)| *id).collect();
1079 let current_positions = self.shared_state.items_before(&ids);
1080 self.shared_state.move_items_to(&entries);
1081 Some(UndoEntry::MovedBatch {
1082 entries: current_positions,
1083 })
1084 }
1085 UndoEntry::Replaced { items, cursor } => {
1086 let (current_items, current_cursor) = self.shared_state.snapshot_playlist();
1087 self.shared_state.restore_playlist(items, cursor);
1088 Some(UndoEntry::Replaced {
1089 items: current_items,
1090 cursor: current_cursor,
1091 })
1092 }
1093 UndoEntry::Batch(entries) => {
1094 let mut inverses = Vec::with_capacity(entries.len());
1096 for entry in entries.into_iter().rev() {
1097 if let Some(inverse) = self.apply_entry(entry) {
1098 inverses.push(inverse);
1099 }
1100 }
1101 inverses.reverse();
1102 Some(UndoEntry::Batch(inverses))
1103 }
1104 }
1105 }
1106
1107 fn execute_undo(&mut self) {
1109 let Some(entry) = self.undo_stack.pop_undo() else {
1110 return;
1111 };
1112 if let Some(inverse) = self.apply_entry(entry) {
1113 self.undo_stack.push_redo(inverse);
1114 }
1115 }
1116
1117 fn execute_redo(&mut self) {
1119 let Some(entry) = self.undo_stack.pop_redo() else {
1120 return;
1121 };
1122 if let Some(inverse) = self.apply_entry(entry) {
1123 self.undo_stack.push_undo_keep_redo(inverse);
1124 }
1125 }
1126
1127 pub fn run(&mut self) {
1129 use std::time::Duration;
1130
1131 let rx = self.commands.rx.clone();
1132 loop {
1133 match rx.recv_timeout(Duration::from_millis(50)) {
1135 Ok(cmd) => self.process_command(cmd),
1136 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1137 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
1138 }
1139 self.update_playback_state();
1140 }
1141 self.stop();
1142 }
1143
1144 pub fn spawn() -> (
1147 Arc<SharedPlayerState>,
1148 Arc<PlaybackTimeline>,
1149 Arc<VizSnapshot>,
1150 crossbeam_channel::Sender<PlayerCommand>,
1151 ) {
1152 let mut player = Self::new();
1153 let state = player.shared_state();
1154 let timeline = player.timeline();
1155 let viz_snapshot = player.viz_snapshot();
1156 let tx = player.command_sender();
1157
1158 thread::Builder::new()
1159 .name("koan-player".into())
1160 .spawn(move || player.run())
1161 .expect("failed to spawn player thread");
1162
1163 (state, timeline, viz_snapshot, tx)
1164 }
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169 use super::*;
1170 use state::PlaylistItem;
1171 use std::path::PathBuf;
1172
1173 fn make_item(title: &str) -> PlaylistItem {
1174 PlaylistItem {
1175 id: QueueItemId::new(),
1176 db_id: None,
1177 path: PathBuf::from(format!("/music/{title}.flac")),
1178 title: title.to_string(),
1179 artist: String::new(),
1180 album_artist: String::new(),
1181 album: String::new(),
1182 year: None,
1183 codec: None,
1184 track_number: None,
1185 disc: None,
1186 duration_ms: None,
1187 load_state: LoadState::Ready,
1188 }
1189 }
1190
1191 fn playlist_ids(player: &Player) -> Vec<QueueItemId> {
1192 let (items, _) = player.shared_state.snapshot_playlist();
1193 items.iter().map(|i| i.id).collect()
1194 }
1195
1196 fn playlist_titles(player: &Player) -> Vec<String> {
1197 let (items, _) = player.shared_state.snapshot_playlist();
1198 items.iter().map(|i| i.title.clone()).collect()
1199 }
1200
1201 #[test]
1204 fn undo_add_removes_items() {
1205 let mut player = Player::new();
1206 let items = vec![make_item("A"), make_item("B")];
1207 let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1208
1209 player.process_command(PlayerCommand::AddToPlaylist(items));
1210 assert_eq!(playlist_ids(&player), ids);
1211 assert!(player.undo_stack().can_undo());
1212
1213 player.process_command(PlayerCommand::Undo);
1214 assert!(playlist_ids(&player).is_empty());
1215 assert!(player.undo_stack().can_redo());
1216 }
1217
1218 #[test]
1219 fn redo_add_restores_items() {
1220 let mut player = Player::new();
1221 let items = vec![make_item("A"), make_item("B")];
1222
1223 player.process_command(PlayerCommand::AddToPlaylist(items));
1224 player.process_command(PlayerCommand::Undo);
1225 assert!(playlist_ids(&player).is_empty());
1226
1227 player.process_command(PlayerCommand::Redo);
1228 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1229 }
1230
1231 #[test]
1234 fn undo_remove_restores_item_at_position() {
1235 let mut player = Player::new();
1236 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1237 let b_id = items[1].id;
1238
1239 player.process_command(PlayerCommand::AddToPlaylist(items));
1240 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1241 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1242
1243 player.process_command(PlayerCommand::Undo);
1244 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1245 }
1246
1247 #[test]
1248 fn undo_remove_first_item() {
1249 let mut player = Player::new();
1250 let items = vec![make_item("A"), make_item("B")];
1251 let a_id = items[0].id;
1252
1253 player.process_command(PlayerCommand::AddToPlaylist(items));
1254 player.process_command(PlayerCommand::RemoveFromPlaylist(a_id));
1255 assert_eq!(playlist_titles(&player), vec!["B"]);
1256
1257 player.process_command(PlayerCommand::Undo);
1258 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1259 }
1260
1261 #[test]
1262 fn undo_batch_remove_restores_all() {
1263 let mut player = Player::new();
1264 let items = vec![
1265 make_item("A"),
1266 make_item("B"),
1267 make_item("C"),
1268 make_item("D"),
1269 ];
1270 let b_id = items[1].id;
1271 let c_id = items[2].id;
1272
1273 player.process_command(PlayerCommand::AddToPlaylist(items));
1274 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![b_id, c_id]));
1275 assert_eq!(playlist_titles(&player), vec!["A", "D"]);
1276
1277 player.process_command(PlayerCommand::Undo);
1279 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1280 }
1281
1282 #[test]
1283 fn redo_batch_remove() {
1284 let mut player = Player::new();
1285 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1286 let a_id = items[0].id;
1287 let b_id = items[1].id;
1288
1289 player.process_command(PlayerCommand::AddToPlaylist(items));
1290 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![a_id, b_id]));
1291 player.process_command(PlayerCommand::Undo);
1292 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1293
1294 player.process_command(PlayerCommand::Redo);
1295 assert_eq!(playlist_titles(&player), vec!["C"]);
1296 }
1297
1298 #[test]
1299 fn redo_remove() {
1300 let mut player = Player::new();
1301 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1302 let b_id = items[1].id;
1303
1304 player.process_command(PlayerCommand::AddToPlaylist(items));
1305 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1306 player.process_command(PlayerCommand::Undo);
1307 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1308
1309 player.process_command(PlayerCommand::Redo);
1310 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1311 }
1312
1313 #[test]
1316 fn undo_insert_removes_inserted_items() {
1317 let mut player = Player::new();
1318 let items = vec![make_item("A"), make_item("C")];
1319 let a_id = items[0].id;
1320
1321 player.process_command(PlayerCommand::AddToPlaylist(items));
1322
1323 let inserted = vec![make_item("B")];
1324 player.process_command(PlayerCommand::InsertInPlaylist {
1325 items: inserted,
1326 after: a_id,
1327 });
1328 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1329
1330 player.process_command(PlayerCommand::Undo);
1331 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1332 }
1333
1334 #[test]
1337 fn undo_move_restores_position() {
1338 let mut player = Player::new();
1339 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1340 let a_id = items[0].id;
1341 let c_id = items[2].id;
1342
1343 player.process_command(PlayerCommand::AddToPlaylist(items));
1344
1345 player.process_command(PlayerCommand::MoveInPlaylist {
1347 id: a_id,
1348 target: c_id,
1349 after: true,
1350 });
1351 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1352
1353 player.process_command(PlayerCommand::Undo);
1354 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1355 }
1356
1357 #[test]
1358 fn redo_move() {
1359 let mut player = Player::new();
1360 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1361 let a_id = items[0].id;
1362 let c_id = items[2].id;
1363
1364 player.process_command(PlayerCommand::AddToPlaylist(items));
1365 player.process_command(PlayerCommand::MoveInPlaylist {
1366 id: a_id,
1367 target: c_id,
1368 after: true,
1369 });
1370 player.process_command(PlayerCommand::Undo);
1371 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1372
1373 player.process_command(PlayerCommand::Redo);
1374 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1375 }
1376
1377 #[test]
1380 fn undo_batch_move() {
1381 let mut player = Player::new();
1382 let items = vec![
1383 make_item("A"),
1384 make_item("B"),
1385 make_item("C"),
1386 make_item("D"),
1387 ];
1388 let a_id = items[0].id;
1389 let b_id = items[1].id;
1390 let d_id = items[3].id;
1391
1392 player.process_command(PlayerCommand::AddToPlaylist(items));
1393
1394 player.process_command(PlayerCommand::MoveItemsInPlaylist {
1396 ids: vec![a_id, b_id],
1397 target: d_id,
1398 after: true,
1399 });
1400 assert_eq!(playlist_titles(&player), vec!["C", "D", "A", "B"]);
1401
1402 player.process_command(PlayerCommand::Undo);
1403 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1404 }
1405
1406 #[test]
1409 fn undo_clear_restores_playlist() {
1410 let mut player = Player::new();
1411 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1412
1413 player.process_command(PlayerCommand::AddToPlaylist(items));
1414 player.process_command(PlayerCommand::ClearPlaylist);
1415 assert!(playlist_ids(&player).is_empty());
1416
1417 player.process_command(PlayerCommand::Undo);
1418 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1419 }
1420
1421 #[test]
1422 fn redo_clear() {
1423 let mut player = Player::new();
1424 let items = vec![make_item("A"), make_item("B")];
1425
1426 player.process_command(PlayerCommand::AddToPlaylist(items));
1427 player.process_command(PlayerCommand::ClearPlaylist);
1428 player.process_command(PlayerCommand::Undo);
1429 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1430
1431 player.process_command(PlayerCommand::Redo);
1432 assert!(playlist_ids(&player).is_empty());
1433 }
1434
1435 #[test]
1438 fn multiple_undos_in_sequence() {
1439 let mut player = Player::new();
1440
1441 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("A")]));
1442 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
1443 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("C")]));
1444 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1445
1446 player.process_command(PlayerCommand::Undo);
1447 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1448
1449 player.process_command(PlayerCommand::Undo);
1450 assert_eq!(playlist_titles(&player), vec!["A"]);
1451
1452 player.process_command(PlayerCommand::Undo);
1453 assert!(playlist_ids(&player).is_empty());
1454 }
1455
1456 #[test]
1457 fn undo_redo_undo_cycle() {
1458 let mut player = Player::new();
1459 let items = vec![make_item("A"), make_item("B")];
1460
1461 player.process_command(PlayerCommand::AddToPlaylist(items));
1462 player.process_command(PlayerCommand::Undo);
1463 assert!(playlist_ids(&player).is_empty());
1464
1465 player.process_command(PlayerCommand::Redo);
1466 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1467
1468 player.process_command(PlayerCommand::Undo);
1469 assert!(playlist_ids(&player).is_empty());
1470 }
1471
1472 #[test]
1473 fn new_action_clears_redo_stack() {
1474 let mut player = Player::new();
1475 let items = vec![make_item("A")];
1476
1477 player.process_command(PlayerCommand::AddToPlaylist(items));
1478 player.process_command(PlayerCommand::Undo);
1479 assert!(player.undo_stack().can_redo());
1480
1481 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
1483 assert!(!player.undo_stack().can_redo());
1484 }
1485
1486 #[test]
1487 fn undo_on_empty_stack_is_noop() {
1488 let mut player = Player::new();
1489 player.process_command(PlayerCommand::Undo);
1490 assert!(playlist_ids(&player).is_empty());
1491 }
1492
1493 #[test]
1494 fn redo_on_empty_stack_is_noop() {
1495 let mut player = Player::new();
1496 player.process_command(PlayerCommand::Redo);
1497 assert!(playlist_ids(&player).is_empty());
1498 }
1499
1500 #[test]
1503 fn playback_commands_not_undoable() {
1504 let mut player = Player::new();
1505 player.process_command(PlayerCommand::Pause);
1506 player.process_command(PlayerCommand::Resume);
1507 player.process_command(PlayerCommand::NextTrack);
1508 player.process_command(PlayerCommand::PrevTrack);
1509 assert!(!player.undo_stack().can_undo());
1510 }
1511
1512 #[test]
1513 fn update_paths_not_undoable() {
1514 let mut player = Player::new();
1515 let items = vec![make_item("A")];
1516 let id = items[0].id;
1517 player.process_command(PlayerCommand::AddToPlaylist(items));
1518
1519 let undo_count = player.undo_stack().undo_len();
1520 player.process_command(PlayerCommand::UpdatePaths(vec![(
1521 id,
1522 PathBuf::from("/new/path.flac"),
1523 )]));
1524 assert_eq!(player.undo_stack().undo_len(), undo_count);
1525 }
1526
1527 #[test]
1530 fn add_remove_undo_undo_produces_original() {
1531 let mut player = Player::new();
1532 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1533 let b_id = items[1].id;
1534 let original_titles = vec!["A", "B", "C"];
1535
1536 player.process_command(PlayerCommand::AddToPlaylist(items));
1537 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1538 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1539
1540 player.process_command(PlayerCommand::Undo);
1542 assert_eq!(playlist_titles(&player), original_titles);
1543
1544 player.process_command(PlayerCommand::Undo);
1546 assert!(playlist_ids(&player).is_empty());
1547 }
1548
1549 #[test]
1550 fn interleaved_adds_and_moves_undo() {
1551 let mut player = Player::new();
1552 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1553 let a_id = items[0].id;
1554 let c_id = items[2].id;
1555
1556 player.process_command(PlayerCommand::AddToPlaylist(items));
1557
1558 player.process_command(PlayerCommand::MoveInPlaylist {
1560 id: a_id,
1561 target: c_id,
1562 after: true,
1563 });
1564 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1565
1566 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("D")]));
1568 assert_eq!(playlist_titles(&player), vec!["B", "C", "A", "D"]);
1569
1570 player.process_command(PlayerCommand::Undo);
1572 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1573
1574 player.process_command(PlayerCommand::Undo);
1576 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1577 }
1578
1579 #[test]
1584 fn stop_engine_drops_engine_synchronously() {
1585 use std::sync::atomic::AtomicBool;
1586
1587 struct MockEngine {
1588 dropped: Arc<AtomicBool>,
1589 }
1590 impl AudioEngineHandle for MockEngine {
1591 fn start(&self) -> Result<(), BackendError> {
1592 Ok(())
1593 }
1594 fn stop(&self) -> Result<(), BackendError> {
1595 Ok(())
1596 }
1597 fn is_running(&self) -> bool {
1598 false
1599 }
1600 }
1601 impl Drop for MockEngine {
1602 fn drop(&mut self) {
1603 self.dropped.store(true, Ordering::SeqCst);
1604 }
1605 }
1606
1607 let dropped = Arc::new(AtomicBool::new(false));
1608
1609 let stop_flag = Arc::new(AtomicBool::new(false));
1611 let decode_handle = buffer::DecodeHandle::new_for_test(stop_flag);
1612
1613 let mut player = Player::new();
1614 player.active_playback = Some(ActivePlayback {
1615 engine: Box::new(MockEngine {
1616 dropped: dropped.clone(),
1617 }),
1618 decode_handle,
1619 });
1620
1621 player.stop_engine();
1622
1623 assert!(
1627 dropped.load(Ordering::SeqCst),
1628 "AudioEngine must be dropped synchronously in stop_engine (GitHub #89)"
1629 );
1630 }
1631}