Browse and listen to thousands of radio stations across the globe right from your terminal ๐ ๐ป ๐ตโจ
radio
rust
tokio
web-radio
command-line-tool
tui
1use crossterm::event::{self, Event, KeyCode, KeyModifiers, MediaKeyCode};
2use ratatui::{
3 prelude::*,
4 widgets::{block::*, *},
5};
6use souvlaki::MediaControlEvent;
7use std::{
8 io,
9 ops::Range,
10 sync::{mpsc::Receiver, Arc, Mutex},
11 thread,
12 time::{Duration, Instant},
13};
14use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
15use tunein_cli::os_media_controls::{self, OsMediaControls};
16
17use crate::{
18 extract::get_currently_playing,
19 input::stream_to_matrix,
20 play::SinkCommand,
21 tui,
22 visualization::{
23 oscilloscope::Oscilloscope, spectroscope::Spectroscope, vectorscope::Vectorscope,
24 Dimension, DisplayMode, GraphConfig,
25 },
26};
27
28#[derive(Debug, Default, Clone)]
29pub struct State {
30 pub name: String,
31 pub now_playing: String,
32 pub genre: String,
33 pub description: String,
34 pub br: String,
35 /// [`Volume`].
36 pub volume: Volume,
37}
38
39/// Volume of the player.
40#[derive(Debug, Clone, PartialEq)]
41pub struct Volume {
42 /// Raw volume stored as percentage.
43 raw_volume_percent: f32,
44 /// Is muted?
45 is_muted: bool,
46}
47
48impl Volume {
49 /// Create a new [`Volume`].
50 pub const fn new(raw_volume_percent: f32, is_muted: bool) -> Self {
51 Self {
52 raw_volume_percent,
53 is_muted,
54 }
55 }
56
57 /// Get the current volume ratio. Returns `0.0` if muted.
58 pub const fn volume_ratio(&self) -> f32 {
59 if self.is_muted {
60 0.0
61 } else {
62 self.raw_volume_percent / 100.0
63 }
64 }
65
66 /// Get the raw volume percent.
67 pub const fn raw_volume_percent(&self) -> f32 {
68 self.raw_volume_percent
69 }
70
71 /// Is volume muted?
72 pub const fn is_muted(&self) -> bool {
73 self.is_muted
74 }
75
76 /// Toggle mute.
77 pub const fn toggle_mute(&mut self) {
78 self.is_muted = !self.is_muted;
79 }
80
81 /// Set the volume to the given volume ratio.
82 ///
83 /// `1.0` is 100% volume.
84 pub const fn set_volume_ratio(&mut self, volume: f32) {
85 self.raw_volume_percent = volume * 100.0;
86 self.raw_volume_percent = self.raw_volume_percent.max(0.0);
87 }
88
89 /// Change the volume by the given step percent.
90 ///
91 /// To increase the volume, use a positive step. To decrease the
92 /// volume, use a negative step.
93 pub const fn change_volume(&mut self, step_percent: f32) {
94 self.raw_volume_percent += step_percent;
95 // limit to 0 volume, no upper bound
96 self.raw_volume_percent = self.raw_volume_percent.max(0.0);
97 }
98}
99
100impl Default for Volume {
101 fn default() -> Self {
102 Self::new(100.0, false)
103 }
104}
105
106pub enum CurrentDisplayMode {
107 Oscilloscope,
108 Vectorscope,
109 Spectroscope,
110 None,
111}
112
113impl std::str::FromStr for CurrentDisplayMode {
114 type Err = InvalidDisplayModeError;
115
116 fn from_str(s: &str) -> Result<Self, Self::Err> {
117 match s {
118 "Oscilloscope" => Ok(Self::Oscilloscope),
119 "Vectorscope" => Ok(Self::Vectorscope),
120 "Spectroscope" => Ok(Self::Spectroscope),
121 "None" => Ok(Self::None),
122 _ => Err(InvalidDisplayModeError),
123 }
124 }
125}
126
127/// Invalid display mode error.
128#[derive(Debug)]
129pub struct InvalidDisplayModeError;
130
131impl std::fmt::Display for InvalidDisplayModeError {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 write!(f, "invalid display mode")
134 }
135}
136
137impl std::error::Error for InvalidDisplayModeError {}
138
139pub struct App {
140 #[allow(unused)]
141 channels: u8,
142 graph: GraphConfig,
143 oscilloscope: Oscilloscope,
144 vectorscope: Vectorscope,
145 spectroscope: Spectroscope,
146 mode: CurrentDisplayMode,
147 frame_rx: Receiver<minimp3::Frame>,
148 /// [`OsMediaControls`].
149 os_media_controls: Option<OsMediaControls>,
150 /// Poll for events every specified [`Duration`].
151 ///
152 /// Allows user to decide the trade off between computational
153 /// resource comsumption, animation smoothness and how responsive
154 /// the application. Smaller durations lead to more resource
155 /// consumption but smoother animations and better responsiveness.
156 poll_events_every: Duration,
157 /// [`Self::poll_events_every`] but when player is paused.
158 ///
159 /// This should generally be larger than
160 /// [`Self::poll_events_every`].
161 poll_events_every_while_paused: Duration,
162}
163
164impl App {
165 pub fn new(
166 ui: &crate::cfg::UiOptions,
167 source: &crate::cfg::SourceOptions,
168 frame_rx: Receiver<minimp3::Frame>,
169 mode: CurrentDisplayMode,
170 os_media_controls: Option<OsMediaControls>,
171 poll_events_every: Duration,
172 poll_events_every_while_paused: Duration,
173 ) -> Self {
174 let graph = GraphConfig {
175 axis_color: Color::DarkGray,
176 labels_color: Color::Cyan,
177 palette: vec![Color::Red, Color::Yellow, Color::Green, Color::Magenta],
178 scale: ui.scale as f64,
179 width: source.buffer, // TODO also make bit depth customizable
180 samples: source.buffer,
181 sampling_rate: source.sample_rate,
182 references: !ui.no_reference,
183 show_ui: !ui.no_ui,
184 scatter: ui.scatter,
185 pause: false,
186 marker_type: if ui.no_braille {
187 Marker::Dot
188 } else {
189 Marker::Braille
190 },
191 };
192
193 let oscilloscope = Oscilloscope::from_args(source);
194 let vectorscope = Vectorscope::from_args(source);
195 let spectroscope = Spectroscope::from_args(source);
196
197 Self {
198 graph,
199 oscilloscope,
200 vectorscope,
201 spectroscope,
202 mode,
203 channels: source.channels as u8,
204 frame_rx,
205 os_media_controls,
206 poll_events_every,
207 poll_events_every_while_paused,
208 }
209 }
210}
211
212fn render_frame(state: Arc<Mutex<State>>, frame: &mut Frame) {
213 let state = state.lock().unwrap();
214 let size = frame.size();
215
216 frame.render_widget(
217 Block::new()
218 .borders(Borders::TOP)
219 .title(" TuneIn CLI ")
220 .title_alignment(Alignment::Center),
221 Rect {
222 x: size.x,
223 y: size.y,
224 width: size.width,
225 height: 1,
226 },
227 );
228
229 render_line(
230 "Station ",
231 &state.name,
232 Rect {
233 x: size.x,
234 y: size.y + 1,
235 width: size.width,
236 height: 1,
237 },
238 frame,
239 );
240
241 if !state.now_playing.is_empty() {
242 render_line(
243 "Now Playing ",
244 &state.now_playing,
245 Rect {
246 x: size.x,
247 y: size.y + 2,
248 width: size.width,
249 height: 1,
250 },
251 frame,
252 );
253 }
254
255 render_line(
256 "Genre ",
257 &state.genre,
258 Rect {
259 x: size.x,
260 y: match state.now_playing.is_empty() {
261 true => size.y + 2,
262 false => size.y + 3,
263 },
264 width: size.width,
265 height: 1,
266 },
267 frame,
268 );
269 render_line(
270 "Description ",
271 &state.description,
272 Rect {
273 x: size.x,
274 y: match state.now_playing.is_empty() {
275 true => size.y + 3,
276 false => size.y + 4,
277 },
278 width: size.width,
279 height: 1,
280 },
281 frame,
282 );
283 render_line(
284 "Bitrate ",
285 &match state.br.is_empty() {
286 true => "Unknown".to_string(),
287 false => format!("{} kbps", &state.br),
288 },
289 Rect {
290 x: size.x,
291 y: match state.now_playing.is_empty() {
292 true => size.y + 4,
293 false => size.y + 5,
294 },
295 width: size.width,
296 height: 1,
297 },
298 frame,
299 );
300 render_line(
301 "Volume ",
302 &if state.volume.is_muted() {
303 format!("{}% muted", state.volume.raw_volume_percent())
304 } else {
305 format!("{}%", state.volume.raw_volume_percent())
306 },
307 Rect {
308 x: size.x,
309 y: match state.now_playing.is_empty() {
310 true => size.y + 5,
311 false => size.y + 6,
312 },
313 width: size.width,
314 height: 1,
315 },
316 frame,
317 )
318}
319
320fn render_line(label: &str, value: &str, area: Rect, frame: &mut Frame) {
321 let span1 = Span::styled(label, Style::new().fg(Color::LightBlue));
322 let span2 = Span::raw(value);
323
324 let line = Line::from(vec![span1, span2]);
325 let text: Text = Text::from(vec![line]);
326
327 frame.render_widget(Paragraph::new(text), area);
328}
329
330impl App {
331 /// runs the application's main loop until the user quits
332 pub async fn run(
333 &mut self,
334 terminal: &mut tui::Tui,
335 mut cmd_rx: UnboundedReceiver<State>,
336 mut sink_cmd_tx: UnboundedSender<SinkCommand>,
337 id: &str,
338 ) {
339 let new_state = cmd_rx.recv().await.unwrap();
340
341 let now_playing = new_state.now_playing.clone();
342 let name = new_state.name.clone();
343 // Report initial metadata to OS
344 send_os_media_controls_command(
345 self.os_media_controls.as_mut(),
346 os_media_controls::Command::SetMetadata(souvlaki::MediaMetadata {
347 title: (!now_playing.is_empty()).then(|| now_playing.as_str()),
348 album: (!name.is_empty()).then(|| name.as_str()),
349 artist: None,
350 cover_url: None,
351 duration: None,
352 }),
353 );
354 // Report started playing to OS
355 send_os_media_controls_command(
356 self.os_media_controls.as_mut(),
357 os_media_controls::Command::Play,
358 );
359 // Report volume to OS
360 send_os_media_controls_command(
361 self.os_media_controls.as_mut(),
362 os_media_controls::Command::SetVolume(new_state.volume.volume_ratio() as f64),
363 );
364
365 let new_state = Arc::new(Mutex::new(new_state));
366 let id = id.to_string();
367 let new_state_clone = new_state.clone();
368
369 // Background thread to update now_playing
370 thread::spawn(move || {
371 let rt = tokio::runtime::Runtime::new().unwrap();
372 rt.block_on(async {
373 loop {
374 let mut new_state = new_state_clone.lock().unwrap();
375 // Get current playing if available, otherwise use default
376 let now_playing = get_currently_playing(&id).await.unwrap_or_default();
377 if new_state.now_playing != now_playing {
378 new_state.now_playing = now_playing;
379 }
380 drop(new_state);
381 std::thread::sleep(Duration::from_millis(10000));
382 }
383 });
384 });
385
386 let mut fps = 0;
387 let mut framerate = 0;
388 let mut last_poll = Instant::now();
389 let mut last_metadata_update = Instant::now();
390 let mut last_now_playing = String::new();
391 const METADATA_UPDATE_INTERVAL: Duration = Duration::from_secs(1); // Check every second
392
393 loop {
394 let channels = if self.graph.pause {
395 None
396 } else {
397 let Ok(audio_frame) = self.frame_rx.recv() else {
398 // other thread has closed so application has closed
399 return;
400 };
401 Some(stream_to_matrix(
402 audio_frame.data.iter().cloned(),
403 audio_frame.channels,
404 1.,
405 ))
406 };
407
408 fps += 1;
409
410 if last_poll.elapsed().as_secs() >= 1 {
411 framerate = fps;
412 fps = 0;
413 last_poll = Instant::now();
414 }
415
416 {
417 let mut datasets = Vec::new();
418 let graph = self.graph.clone(); // TODO cheap fix...
419 if self.graph.references {
420 if let Some(current_display) = self.current_display() {
421 datasets.append(&mut current_display.references(&graph));
422 }
423 }
424 if let Some((current_display, channels)) = self.current_display_mut().zip(channels)
425 {
426 datasets.append(&mut current_display.process(&graph, &channels));
427 }
428 terminal
429 .draw(|f| {
430 let mut size = f.size();
431 render_frame(new_state.clone(), f);
432 if let Some(current_display) = self.current_display() {
433 if self.graph.show_ui {
434 f.render_widget(
435 make_header(
436 &self.graph,
437 ¤t_display.header(&self.graph),
438 current_display.mode_str(),
439 framerate,
440 self.graph.pause,
441 ),
442 Rect {
443 x: size.x,
444 y: size.y + 7,
445 width: size.width,
446 height: 1,
447 },
448 );
449 size.height -= 8;
450 size.y += 8;
451 }
452 let chart = Chart::new(datasets.iter().map(|x| x.into()).collect())
453 .x_axis(current_display.axis(&self.graph, Dimension::X))
454 .y_axis(current_display.axis(&self.graph, Dimension::Y));
455 f.render_widget(chart, size)
456 }
457 })
458 .unwrap();
459
460 // Update metadata only if needed and at a controlled interval
461 if last_metadata_update.elapsed() >= METADATA_UPDATE_INTERVAL {
462 let state = new_state.lock().unwrap();
463 if state.now_playing != last_now_playing {
464 let now_playing = state.now_playing.clone();
465 let name = state.name.clone();
466 send_os_media_controls_command(
467 self.os_media_controls.as_mut(),
468 os_media_controls::Command::SetMetadata(souvlaki::MediaMetadata {
469 title: (!now_playing.is_empty()).then_some(now_playing.as_str()),
470 album: (!name.is_empty()).then_some(name.as_str()),
471 artist: None,
472 cover_url: None,
473 duration: None,
474 }),
475 );
476 last_now_playing = state.now_playing.clone();
477 }
478 last_metadata_update = Instant::now();
479 }
480 }
481
482 while let Some(event) = self
483 .os_media_controls
484 .as_mut()
485 .and_then(|os_media_controls| os_media_controls.try_recv_os_event())
486 {
487 if self.process_os_media_control_event(event, &new_state, &mut sink_cmd_tx) {
488 return;
489 }
490 }
491
492 let timeout_duration = if self.graph.pause {
493 self.poll_events_every_while_paused
494 } else {
495 self.poll_events_every
496 };
497
498 while event::poll(timeout_duration).unwrap() {
499 // process all enqueued events
500 let event = event::read().unwrap();
501
502 if self
503 .process_events(event.clone(), new_state.clone(), &mut sink_cmd_tx)
504 .unwrap()
505 {
506 return;
507 }
508 if let Some(current_display) = self.current_display_mut() {
509 current_display.handle(event);
510 }
511 }
512 }
513 }
514 fn current_display_mut(&mut self) -> Option<&mut dyn DisplayMode> {
515 match self.mode {
516 CurrentDisplayMode::Oscilloscope => {
517 Some(&mut self.oscilloscope as &mut dyn DisplayMode)
518 }
519 CurrentDisplayMode::Vectorscope => Some(&mut self.vectorscope as &mut dyn DisplayMode),
520 CurrentDisplayMode::Spectroscope => {
521 Some(&mut self.spectroscope as &mut dyn DisplayMode)
522 }
523 CurrentDisplayMode::None => None,
524 }
525 }
526
527 fn current_display(&self) -> Option<&dyn DisplayMode> {
528 match self.mode {
529 CurrentDisplayMode::Oscilloscope => Some(&self.oscilloscope as &dyn DisplayMode),
530 CurrentDisplayMode::Vectorscope => Some(&self.vectorscope as &dyn DisplayMode),
531 CurrentDisplayMode::Spectroscope => Some(&self.spectroscope as &dyn DisplayMode),
532 CurrentDisplayMode::None => None,
533 }
534 }
535
536 fn process_events(
537 &mut self,
538 event: Event,
539 state: Arc<Mutex<State>>,
540 sink_cmd_tx: &mut UnboundedSender<SinkCommand>,
541 ) -> Result<bool, io::Error> {
542 let mut quit = false;
543
544 if let Event::Key(key) = event {
545 if let KeyModifiers::CONTROL = key.modifiers {
546 match key.code {
547 // mimic other programs shortcuts to quit, for user friendlyness
548 KeyCode::Char('c') | KeyCode::Char('q') | KeyCode::Char('w') => quit = true,
549 _ => {}
550 }
551 }
552 let magnitude = match key.modifiers {
553 KeyModifiers::SHIFT => 10.0,
554 KeyModifiers::CONTROL => 5.0,
555 KeyModifiers::ALT => 0.2,
556 _ => 1.0,
557 };
558 match key.code {
559 KeyCode::Up => {
560 // inverted to act as zoom
561 update_value_f(&mut self.graph.scale, 0.01, magnitude, 0.0..10.0);
562 raise_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx);
563 }
564 KeyCode::Down => {
565 // inverted to act as zoom
566 update_value_f(&mut self.graph.scale, -0.01, magnitude, 0.0..10.0);
567 lower_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx);
568 }
569 KeyCode::Right => update_value_i(
570 &mut self.graph.samples,
571 true,
572 25,
573 magnitude,
574 0..self.graph.width * 2,
575 ),
576 KeyCode::Left => update_value_i(
577 &mut self.graph.samples,
578 false,
579 25,
580 magnitude,
581 0..self.graph.width * 2,
582 ),
583 KeyCode::Char('q') => quit = true,
584 KeyCode::Char(' ') => toggle_play_pause(
585 &mut self.graph,
586 self.os_media_controls.as_mut(),
587 sink_cmd_tx,
588 ),
589 KeyCode::Char('s') => self.graph.scatter = !self.graph.scatter,
590 KeyCode::Char('h') => self.graph.show_ui = !self.graph.show_ui,
591 KeyCode::Char('r') => self.graph.references = !self.graph.references,
592 KeyCode::Char('m') => {
593 mute_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx)
594 }
595 KeyCode::Esc => {
596 self.graph.samples = self.graph.width;
597 self.graph.scale = 1.;
598 }
599 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
600 quit = true;
601 }
602 KeyCode::Tab => {
603 // switch modes
604 match self.mode {
605 CurrentDisplayMode::Oscilloscope => {
606 self.mode = CurrentDisplayMode::Vectorscope;
607 }
608 CurrentDisplayMode::Vectorscope => {
609 self.mode = CurrentDisplayMode::Spectroscope;
610 }
611 CurrentDisplayMode::Spectroscope => {
612 self.mode = CurrentDisplayMode::None;
613 }
614 CurrentDisplayMode::None => {
615 self.mode = CurrentDisplayMode::Oscilloscope;
616 }
617 }
618 }
619 KeyCode::Media(media_key_code) => match media_key_code {
620 MediaKeyCode::Play => play(
621 &mut self.graph,
622 self.os_media_controls.as_mut(),
623 sink_cmd_tx,
624 ),
625 MediaKeyCode::Pause => pause(
626 &mut self.graph,
627 self.os_media_controls.as_mut(),
628 sink_cmd_tx,
629 ),
630 MediaKeyCode::PlayPause => toggle_play_pause(
631 &mut self.graph,
632 self.os_media_controls.as_mut(),
633 sink_cmd_tx,
634 ),
635 MediaKeyCode::Stop => {
636 quit = true;
637 }
638 MediaKeyCode::LowerVolume => {
639 lower_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx)
640 }
641 MediaKeyCode::RaiseVolume => {
642 raise_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx)
643 }
644 MediaKeyCode::MuteVolume => {
645 mute_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx)
646 }
647 MediaKeyCode::TrackNext
648 | MediaKeyCode::TrackPrevious
649 | MediaKeyCode::Reverse
650 | MediaKeyCode::FastForward
651 | MediaKeyCode::Rewind
652 | MediaKeyCode::Record => {}
653 },
654 _ => {}
655 }
656 };
657
658 Ok(quit)
659 }
660
661 /// Process OS media control event.
662 ///
663 /// Returns [`true`] if application should quit.
664 fn process_os_media_control_event(
665 &mut self,
666 event: MediaControlEvent,
667 state: &Mutex<State>,
668 sink_cmd_tx: &mut UnboundedSender<SinkCommand>,
669 ) -> bool {
670 let mut quit = false;
671
672 match event {
673 MediaControlEvent::Play => {
674 play(
675 &mut self.graph,
676 self.os_media_controls.as_mut(),
677 sink_cmd_tx,
678 );
679 }
680 MediaControlEvent::Pause => {
681 pause(
682 &mut self.graph,
683 self.os_media_controls.as_mut(),
684 sink_cmd_tx,
685 );
686 }
687 MediaControlEvent::Toggle => {
688 toggle_play_pause(
689 &mut self.graph,
690 self.os_media_controls.as_mut(),
691 sink_cmd_tx,
692 );
693 }
694 MediaControlEvent::Stop | MediaControlEvent::Quit => {
695 quit = true;
696 }
697 MediaControlEvent::SetVolume(volume) => {
698 set_volume_ratio(
699 volume as f32,
700 state,
701 self.os_media_controls.as_mut(),
702 sink_cmd_tx,
703 );
704 }
705 MediaControlEvent::Next
706 | MediaControlEvent::Previous
707 | MediaControlEvent::Seek(_)
708 | MediaControlEvent::SeekBy(_, _)
709 | MediaControlEvent::SetPosition(_)
710 | MediaControlEvent::OpenUri(_)
711 | MediaControlEvent::Raise => {}
712 }
713
714 quit
715 }
716}
717
718pub fn update_value_f(val: &mut f64, base: f64, magnitude: f64, range: Range<f64>) {
719 let delta = base * magnitude;
720 if *val + delta > range.end {
721 *val = range.end
722 } else if *val + delta < range.start {
723 *val = range.start
724 } else {
725 *val += delta;
726 }
727}
728
729pub fn update_value_i(val: &mut u32, inc: bool, base: u32, magnitude: f64, range: Range<u32>) {
730 let delta = (base as f64 * magnitude) as u32;
731 if inc {
732 if range.end - delta < *val {
733 *val = range.end
734 } else {
735 *val += delta
736 }
737 } else if range.start + delta > *val {
738 *val = range.start
739 } else {
740 *val -= delta
741 }
742}
743
744fn make_header<'a>(
745 cfg: &GraphConfig,
746 module_header: &'a str,
747 kind_o_scope: &'static str,
748 fps: usize,
749 pause: bool,
750) -> Table<'a> {
751 Table::new(
752 vec![Row::new(vec![
753 Cell::from(format!("{}::scope-tui", kind_o_scope)).style(
754 Style::default()
755 .fg(*cfg.palette.first().expect("empty palette?"))
756 .add_modifier(Modifier::BOLD),
757 ),
758 Cell::from(module_header),
759 Cell::from(format!("-{:.2}x+", cfg.scale)),
760 Cell::from(format!("{}/{} spf", cfg.samples, cfg.width)),
761 Cell::from(format!("{}fps", fps)),
762 Cell::from(if cfg.scatter { "***" } else { "---" }),
763 Cell::from(if pause { "||" } else { "|>" }),
764 ])],
765 vec![
766 Constraint::Percentage(35),
767 Constraint::Percentage(25),
768 Constraint::Percentage(7),
769 Constraint::Percentage(13),
770 Constraint::Percentage(6),
771 Constraint::Percentage(6),
772 Constraint::Percentage(6),
773 ],
774 )
775 .style(Style::default().fg(cfg.labels_color))
776}
777
778/// Play music.
779fn play(
780 graph: &mut GraphConfig,
781 os_media_controls: Option<&mut OsMediaControls>,
782 sink_cmd_tx: &UnboundedSender<SinkCommand>,
783) {
784 graph.pause = false;
785 send_os_media_controls_command(os_media_controls, os_media_controls::Command::Play);
786 sink_cmd_tx
787 .send(SinkCommand::Play)
788 .expect("receiver never dropped");
789}
790
791/// Pause music.
792fn pause(
793 graph: &mut GraphConfig,
794 os_media_controls: Option<&mut OsMediaControls>,
795 sink_cmd_tx: &UnboundedSender<SinkCommand>,
796) {
797 graph.pause = true;
798 send_os_media_controls_command(os_media_controls, os_media_controls::Command::Pause);
799 sink_cmd_tx
800 .send(SinkCommand::Pause)
801 .expect("receiver never dropped");
802}
803
804/// Toggle between play and pause.
805fn toggle_play_pause(
806 graph: &mut GraphConfig,
807 os_media_controls: Option<&mut OsMediaControls>,
808 sink_cmd_tx: &UnboundedSender<SinkCommand>,
809) {
810 graph.pause = !graph.pause;
811 let (sink_cmd, os_media_controls_command) = if graph.pause {
812 (SinkCommand::Pause, os_media_controls::Command::Pause)
813 } else {
814 (SinkCommand::Play, os_media_controls::Command::Play)
815 };
816 send_os_media_controls_command(os_media_controls, os_media_controls_command);
817 sink_cmd_tx.send(sink_cmd).expect("receiver never dropped");
818}
819
820/// Lower the volume.
821fn lower_volume(
822 state: &Mutex<State>,
823 os_media_controls: Option<&mut OsMediaControls>,
824 sink_cmd_tx: &UnboundedSender<SinkCommand>,
825) {
826 let mut state = state.lock().unwrap();
827 state.volume.change_volume(-1.0);
828 send_os_media_controls_command(
829 os_media_controls,
830 os_media_controls::Command::SetVolume(state.volume.volume_ratio() as f64),
831 );
832 sink_cmd_tx
833 .send(SinkCommand::SetVolume(state.volume.volume_ratio()))
834 .expect("receiver never dropped");
835}
836
837/// Raise the volume.
838fn raise_volume(
839 state: &Mutex<State>,
840 os_media_controls: Option<&mut OsMediaControls>,
841 sink_cmd_tx: &UnboundedSender<SinkCommand>,
842) {
843 let mut state = state.lock().unwrap();
844 state.volume.change_volume(1.0);
845 send_os_media_controls_command(
846 os_media_controls,
847 os_media_controls::Command::SetVolume(state.volume.volume_ratio() as f64),
848 );
849 sink_cmd_tx
850 .send(SinkCommand::SetVolume(state.volume.volume_ratio()))
851 .expect("receiver never dropped");
852}
853
854/// Mute the volume.
855fn mute_volume(
856 state: &Mutex<State>,
857 os_media_controls: Option<&mut OsMediaControls>,
858 sink_cmd_tx: &UnboundedSender<SinkCommand>,
859) {
860 let mut state = state.lock().unwrap();
861 state.volume.toggle_mute();
862 send_os_media_controls_command(
863 os_media_controls,
864 os_media_controls::Command::SetVolume(state.volume.volume_ratio() as f64),
865 );
866 sink_cmd_tx
867 .send(SinkCommand::SetVolume(state.volume.volume_ratio()))
868 .expect("receiver never dropped");
869}
870
871/// Set the volume to the given volume ratio.
872fn set_volume_ratio(
873 volume_ratio: f32,
874 state: &Mutex<State>,
875 os_media_controls: Option<&mut OsMediaControls>,
876 sink_cmd_tx: &UnboundedSender<SinkCommand>,
877) {
878 let mut state = state.lock().unwrap();
879 state.volume.set_volume_ratio(volume_ratio);
880 send_os_media_controls_command(
881 os_media_controls,
882 os_media_controls::Command::SetVolume(state.volume.volume_ratio() as f64),
883 );
884 sink_cmd_tx
885 .send(SinkCommand::SetVolume(state.volume.volume_ratio()))
886 .expect("receiver never dropped");
887}
888
889/// Send [`os_media_controls::Command`].
890pub fn send_os_media_controls_command(
891 os_media_controls: Option<&mut OsMediaControls>,
892 command: os_media_controls::Command<'_>,
893) {
894 if let Some(os_media_controls) = os_media_controls {
895 let _ = os_media_controls.send_to_os(command).inspect_err(|err| {
896 eprintln!(
897 "error: failed to send command to OS media controls due to `{}`",
898 err
899 );
900 });
901 }
902}