Browse and listen to thousands of radio stations across the globe right from your terminal ๐ŸŒŽ ๐Ÿ“ป ๐ŸŽตโœจ
radio rust tokio web-radio command-line-tool tui

Merge pull request #30 from ishbosamiya/player_controls

Support for player controls (play, pause, volume control)

authored by tsiry-sandratraina.com and committed by GitHub fe119abb b7ece6cb

Changed files
+328 -58
src
+1 -1
Cargo.toml
··· 33 33 futures-util = "0.3.26" 34 34 hyper = {version = "0.14.23", features = ["client", "stream", "tcp", "http1", "http2"]} 35 35 m3u = "1.0.0" 36 - minimp3 = "0.5.1" 36 + minimp3 = "0.6" 37 37 owo-colors = "3.5.0" 38 38 pls = "0.2.2" 39 39 prost = "0.13.2"
+260 -50
src/app.rs
··· 1 - use crossterm::event::{self, Event, KeyCode, KeyModifiers}; 1 + use crossterm::event::{self, Event, KeyCode, KeyModifiers, MediaKeyCode}; 2 2 use ratatui::{ 3 3 prelude::*, 4 4 widgets::{block::*, *}, ··· 6 6 use std::{ 7 7 io, 8 8 ops::Range, 9 - process, 10 9 sync::{mpsc::Receiver, Arc, Mutex}, 11 10 thread, 12 11 time::{Duration, Instant}, 13 12 }; 14 - use tokio::sync::mpsc::UnboundedReceiver; 13 + use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; 15 14 16 15 use crate::{ 17 16 extract::get_currently_playing, 18 17 input::stream_to_matrix, 18 + play::SinkCommand, 19 19 tui, 20 20 visualization::{ 21 21 oscilloscope::Oscilloscope, spectroscope::Spectroscope, vectorscope::Vectorscope, ··· 30 30 pub genre: String, 31 31 pub description: String, 32 32 pub br: String, 33 + /// [`Volume`]. 34 + pub volume: Volume, 35 + } 36 + 37 + /// Volume of the player. 38 + #[derive(Debug, Clone, PartialEq)] 39 + pub struct Volume { 40 + /// Raw volume stored as percentage. 41 + raw_volume_percent: f32, 42 + /// Is muted? 43 + is_muted: bool, 44 + } 45 + 46 + impl Volume { 47 + /// Create a new [`Volume`]. 48 + pub const fn new(raw_volume_percent: f32, is_muted: bool) -> Self { 49 + Self { 50 + raw_volume_percent, 51 + is_muted, 52 + } 53 + } 54 + 55 + /// Get the current volume ratio. Returns `0.0` if muted. 56 + pub const fn volume_ratio(&self) -> f32 { 57 + if self.is_muted { 58 + 0.0 59 + } else { 60 + self.raw_volume_percent / 100.0 61 + } 62 + } 63 + 64 + /// Get the raw volume percent. 65 + pub const fn raw_volume_percent(&self) -> f32 { 66 + self.raw_volume_percent 67 + } 68 + 69 + /// Is volume muted? 70 + pub const fn is_muted(&self) -> bool { 71 + self.is_muted 72 + } 73 + 74 + /// Toggle mute. 75 + pub const fn toggle_mute(&mut self) { 76 + self.is_muted = !self.is_muted; 77 + } 78 + 79 + /// Change the volume by the given step percent. 80 + /// 81 + /// To increase the volume, use a positive step. To decrease the 82 + /// volume, use a negative step. 83 + pub const fn change_volume(&mut self, step_percent: f32) { 84 + self.raw_volume_percent += step_percent; 85 + // limit to 0 volume, no upper bound 86 + self.raw_volume_percent = self.raw_volume_percent.max(0.0); 87 + } 88 + } 89 + 90 + impl Default for Volume { 91 + fn default() -> Self { 92 + Self::new(100.0, false) 93 + } 33 94 } 34 95 35 96 pub enum CurrentDisplayMode { 36 97 Oscilloscope, 37 98 Vectorscope, 38 99 Spectroscope, 100 + None, 39 101 } 40 102 103 + impl std::str::FromStr for CurrentDisplayMode { 104 + type Err = InvalidDisplayModeError; 105 + 106 + fn from_str(s: &str) -> Result<Self, Self::Err> { 107 + match s { 108 + "Oscilloscope" => Ok(Self::Oscilloscope), 109 + "Vectorscope" => Ok(Self::Vectorscope), 110 + "Spectroscope" => Ok(Self::Spectroscope), 111 + "None" => Ok(Self::None), 112 + _ => Err(InvalidDisplayModeError), 113 + } 114 + } 115 + } 116 + 117 + /// Invalid display mode error. 118 + #[derive(Debug)] 119 + pub struct InvalidDisplayModeError; 120 + 121 + impl std::fmt::Display for InvalidDisplayModeError { 122 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 123 + write!(f, "invalid display mode") 124 + } 125 + } 126 + 127 + impl std::error::Error for InvalidDisplayModeError {} 128 + 41 129 pub struct App { 42 130 #[allow(unused)] 43 131 channels: u8, ··· 54 142 ui: &crate::cfg::UiOptions, 55 143 source: &crate::cfg::SourceOptions, 56 144 frame_rx: Receiver<minimp3::Frame>, 145 + mode: CurrentDisplayMode, 57 146 ) -> Self { 58 147 let graph = GraphConfig { 59 148 axis_color: Color::DarkGray, ··· 83 172 oscilloscope, 84 173 vectorscope, 85 174 spectroscope, 86 - mode: CurrentDisplayMode::Spectroscope, 175 + mode, 87 176 channels: source.channels as u8, 88 177 frame_rx, 89 178 } ··· 178 267 }, 179 268 frame, 180 269 ); 270 + render_line( 271 + "Volume ", 272 + &if state.volume.is_muted() { 273 + format!("{}% muted", state.volume.raw_volume_percent()) 274 + } else { 275 + format!("{}%", state.volume.raw_volume_percent()) 276 + }, 277 + Rect { 278 + x: size.x, 279 + y: match state.now_playing.is_empty() { 280 + true => size.y + 5, 281 + false => size.y + 6, 282 + }, 283 + width: size.width, 284 + height: 1, 285 + }, 286 + frame, 287 + ) 181 288 } 182 289 183 290 fn render_line(label: &str, value: &str, area: Rect, frame: &mut Frame) { ··· 196 303 &mut self, 197 304 terminal: &mut tui::Tui, 198 305 mut cmd_rx: UnboundedReceiver<State>, 306 + mut sink_cmd_tx: UnboundedSender<SinkCommand>, 199 307 id: &str, 200 308 ) { 201 309 let new_state = cmd_rx.recv().await.unwrap(); ··· 220 328 let mut last_poll = Instant::now(); 221 329 222 330 loop { 223 - let audio_frame = self.frame_rx.recv().unwrap(); 224 - let channels = 225 - stream_to_matrix(audio_frame.data.iter().cloned(), audio_frame.channels, 1.); 331 + let channels = (!self.graph.pause) 332 + .then(|| self.frame_rx.recv().unwrap()) 333 + .map(|audio_frame| { 334 + stream_to_matrix(audio_frame.data.iter().cloned(), audio_frame.channels, 1.) 335 + }); 226 336 227 337 fps += 1; 228 338 ··· 236 346 let mut datasets = Vec::new(); 237 347 let graph = self.graph.clone(); // TODO cheap fix... 238 348 if self.graph.references { 239 - datasets.append(&mut self.current_display_mut().references(&graph)); 349 + if let Some(current_display) = self.current_display() { 350 + datasets.append(&mut current_display.references(&graph)); 351 + } 352 + } 353 + if let Some((current_display, channels)) = self.current_display_mut().zip(channels) 354 + { 355 + datasets.append(&mut current_display.process(&graph, &channels)); 240 356 } 241 - datasets.append(&mut self.current_display_mut().process(&graph, &channels)); 242 357 terminal 243 358 .draw(|f| { 244 359 let mut size = f.size(); 245 360 render_frame(new_state.clone(), f); 246 - if self.graph.show_ui { 247 - f.render_widget( 248 - make_header( 249 - &self.graph, 250 - &self.current_display().header(&self.graph), 251 - self.current_display().mode_str(), 252 - framerate, 253 - self.graph.pause, 254 - ), 255 - Rect { 256 - x: size.x, 257 - y: size.y + 6, 258 - width: size.width, 259 - height: 1, 260 - }, 261 - ); 262 - size.height -= 7; 263 - size.y += 7; 361 + if let Some(current_display) = self.current_display() { 362 + if self.graph.show_ui { 363 + f.render_widget( 364 + make_header( 365 + &self.graph, 366 + &current_display.header(&self.graph), 367 + current_display.mode_str(), 368 + framerate, 369 + self.graph.pause, 370 + ), 371 + Rect { 372 + x: size.x, 373 + y: size.y + 7, 374 + width: size.width, 375 + height: 1, 376 + }, 377 + ); 378 + size.height -= 8; 379 + size.y += 8; 380 + } 381 + let chart = Chart::new(datasets.iter().map(|x| x.into()).collect()) 382 + .x_axis(current_display.axis(&self.graph, Dimension::X)) // TODO allow to have axis sometimes? 383 + .y_axis(current_display.axis(&self.graph, Dimension::Y)); 384 + f.render_widget(chart, size) 264 385 } 265 - let chart = Chart::new(datasets.iter().map(|x| x.into()).collect()) 266 - .x_axis(self.current_display().axis(&self.graph, Dimension::X)) // TODO allow to have axis sometimes? 267 - .y_axis(self.current_display().axis(&self.graph, Dimension::Y)); 268 - f.render_widget(chart, size) 269 386 }) 270 387 .unwrap(); 271 388 } ··· 274 391 // process all enqueued events 275 392 let event = event::read().unwrap(); 276 393 277 - if self.process_events(event.clone()).unwrap() { 394 + if self 395 + .process_events(event.clone(), new_state.clone(), &mut sink_cmd_tx) 396 + .unwrap() 397 + { 278 398 return; 279 399 } 280 - self.current_display_mut().handle(event); 400 + if let Some(current_display) = self.current_display_mut() { 401 + current_display.handle(event); 402 + } 281 403 } 282 404 } 283 405 } 284 406 285 - fn current_display_mut(&mut self) -> &mut dyn DisplayMode { 407 + fn current_display_mut(&mut self) -> Option<&mut dyn DisplayMode> { 286 408 match self.mode { 287 - CurrentDisplayMode::Oscilloscope => &mut self.oscilloscope as &mut dyn DisplayMode, 288 - CurrentDisplayMode::Vectorscope => &mut self.vectorscope as &mut dyn DisplayMode, 289 - CurrentDisplayMode::Spectroscope => &mut self.spectroscope as &mut dyn DisplayMode, 409 + CurrentDisplayMode::Oscilloscope => { 410 + Some(&mut self.oscilloscope as &mut dyn DisplayMode) 411 + } 412 + CurrentDisplayMode::Vectorscope => Some(&mut self.vectorscope as &mut dyn DisplayMode), 413 + CurrentDisplayMode::Spectroscope => { 414 + Some(&mut self.spectroscope as &mut dyn DisplayMode) 415 + } 416 + CurrentDisplayMode::None => None, 290 417 } 291 418 } 292 419 293 - fn current_display(&self) -> &dyn DisplayMode { 420 + fn current_display(&self) -> Option<&dyn DisplayMode> { 294 421 match self.mode { 295 - CurrentDisplayMode::Oscilloscope => &self.oscilloscope as &dyn DisplayMode, 296 - CurrentDisplayMode::Vectorscope => &self.vectorscope as &dyn DisplayMode, 297 - CurrentDisplayMode::Spectroscope => &self.spectroscope as &dyn DisplayMode, 422 + CurrentDisplayMode::Oscilloscope => Some(&self.oscilloscope as &dyn DisplayMode), 423 + CurrentDisplayMode::Vectorscope => Some(&self.vectorscope as &dyn DisplayMode), 424 + CurrentDisplayMode::Spectroscope => Some(&self.spectroscope as &dyn DisplayMode), 425 + CurrentDisplayMode::None => None, 298 426 } 299 427 } 300 428 301 - fn process_events(&mut self, event: Event) -> Result<bool, io::Error> { 429 + fn process_events( 430 + &mut self, 431 + event: Event, 432 + state: Arc<Mutex<State>>, 433 + sink_cmd_tx: &mut UnboundedSender<SinkCommand>, 434 + ) -> Result<bool, io::Error> { 302 435 let mut quit = false; 436 + 437 + let play = |graph: &mut GraphConfig| { 438 + graph.pause = false; 439 + sink_cmd_tx 440 + .send(SinkCommand::Play) 441 + .expect("receiver never dropped"); 442 + }; 443 + 444 + let pause = |graph: &mut GraphConfig| { 445 + graph.pause = true; 446 + sink_cmd_tx 447 + .send(SinkCommand::Pause) 448 + .expect("receiver never dropped"); 449 + }; 450 + 451 + let toggle_play_pause = |graph: &mut GraphConfig| { 452 + graph.pause = !graph.pause; 453 + let sink_cmd = if graph.pause { 454 + SinkCommand::Pause 455 + } else { 456 + SinkCommand::Play 457 + }; 458 + sink_cmd_tx.send(sink_cmd).expect("receiver never dropped"); 459 + }; 460 + 461 + let lower_volume = || { 462 + let mut state = state.lock().unwrap(); 463 + state.volume.change_volume(-1.0); 464 + sink_cmd_tx 465 + .send(SinkCommand::SetVolume(state.volume.volume_ratio())) 466 + .expect("receiver never dropped"); 467 + }; 468 + 469 + let raise_volume = || { 470 + let mut state = state.lock().unwrap(); 471 + state.volume.change_volume(1.0); 472 + sink_cmd_tx 473 + .send(SinkCommand::SetVolume(state.volume.volume_ratio())) 474 + .expect("receiver never dropped"); 475 + }; 476 + 477 + let mute_volume = || { 478 + let mut state = state.lock().unwrap(); 479 + state.volume.toggle_mute(); 480 + sink_cmd_tx 481 + .send(SinkCommand::SetVolume(state.volume.volume_ratio())) 482 + .expect("receiver never dropped"); 483 + }; 484 + 303 485 if let Event::Key(key) = event { 304 486 if let KeyModifiers::CONTROL = key.modifiers { 305 487 match key.code { ··· 315 497 _ => 1.0, 316 498 }; 317 499 match key.code { 318 - KeyCode::Up => update_value_f(&mut self.graph.scale, 0.01, magnitude, 0.0..10.0), // inverted to act as zoom 319 - KeyCode::Down => update_value_f(&mut self.graph.scale, -0.01, magnitude, 0.0..10.0), // inverted to act as zoom 500 + KeyCode::Up => { 501 + // inverted to act as zoom 502 + update_value_f(&mut self.graph.scale, 0.01, magnitude, 0.0..10.0); 503 + raise_volume(); 504 + } 505 + KeyCode::Down => { 506 + // inverted to act as zoom 507 + update_value_f(&mut self.graph.scale, -0.01, magnitude, 0.0..10.0); 508 + lower_volume(); 509 + } 320 510 KeyCode::Right => update_value_i( 321 511 &mut self.graph.samples, 322 512 true, ··· 332 522 0..self.graph.width * 2, 333 523 ), 334 524 KeyCode::Char('q') => quit = true, 335 - KeyCode::Char(' ') => self.graph.pause = !self.graph.pause, 525 + KeyCode::Char(' ') => toggle_play_pause(&mut self.graph), 336 526 KeyCode::Char('s') => self.graph.scatter = !self.graph.scatter, 337 527 KeyCode::Char('h') => self.graph.show_ui = !self.graph.show_ui, 338 528 KeyCode::Char('r') => self.graph.references = !self.graph.references, 529 + KeyCode::Char('m') => mute_volume(), 339 530 KeyCode::Esc => { 340 531 self.graph.samples = self.graph.width; 341 532 self.graph.scale = 1.; 342 533 } 343 534 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 344 - let _ = tui::restore(); 345 - process::exit(0); 535 + quit = true; 346 536 } 347 537 KeyCode::Tab => { 348 538 // switch modes 349 539 match self.mode { 350 540 CurrentDisplayMode::Oscilloscope => { 351 - self.mode = CurrentDisplayMode::Vectorscope 541 + self.mode = CurrentDisplayMode::Vectorscope; 352 542 } 353 543 CurrentDisplayMode::Vectorscope => { 354 - self.mode = CurrentDisplayMode::Spectroscope 544 + self.mode = CurrentDisplayMode::Spectroscope; 355 545 } 356 546 CurrentDisplayMode::Spectroscope => { 357 - self.mode = CurrentDisplayMode::Oscilloscope 547 + self.mode = CurrentDisplayMode::None; 548 + } 549 + CurrentDisplayMode::None => { 550 + self.mode = CurrentDisplayMode::Oscilloscope; 358 551 } 359 552 } 360 553 } 554 + KeyCode::Media(media_key_code) => match media_key_code { 555 + MediaKeyCode::Play => play(&mut self.graph), 556 + MediaKeyCode::Pause => pause(&mut self.graph), 557 + MediaKeyCode::PlayPause => toggle_play_pause(&mut self.graph), 558 + MediaKeyCode::Stop => { 559 + quit = true; 560 + } 561 + MediaKeyCode::LowerVolume => lower_volume(), 562 + MediaKeyCode::RaiseVolume => raise_volume(), 563 + MediaKeyCode::MuteVolume => mute_volume(), 564 + MediaKeyCode::TrackNext 565 + | MediaKeyCode::TrackPrevious 566 + | MediaKeyCode::Reverse 567 + | MediaKeyCode::FastForward 568 + | MediaKeyCode::Rewind 569 + | MediaKeyCode::Record => {} 570 + }, 361 571 _ => {} 362 572 } 363 573 };
+11 -2
src/main.rs
··· 1 1 use anyhow::Error; 2 + use app::CurrentDisplayMode; 2 3 use clap::{arg, Command}; 3 4 4 5 mod app; ··· 45 46 .subcommand( 46 47 Command::new("play") 47 48 .about("Play a radio station") 48 - .arg(arg!(<station> "The station to play")), 49 + .arg(arg!(<station> "The station to play")) 50 + .arg(arg!(--volume "Set the initial volume (as a percent)").default_value("100")) 51 + .arg(clap::Arg::new("display-mode").long("display-mode").help("Set the display mode to start with").default_value("Spectroscope")), 49 52 ) 50 53 .subcommand( 51 54 Command::new("browse") ··· 90 93 Some(("play", args)) => { 91 94 let station = args.value_of("station").unwrap(); 92 95 let provider = matches.value_of("provider").unwrap(); 93 - play::exec(station, provider).await?; 96 + let volume = args.value_of("volume").unwrap().parse::<f32>().unwrap(); 97 + let display_mode = args 98 + .value_of("display-mode") 99 + .unwrap() 100 + .parse::<CurrentDisplayMode>() 101 + .unwrap(); 102 + play::exec(station, provider, volume, display_mode).await?; 94 103 } 95 104 Some(("browse", args)) => { 96 105 let category = args.value_of("category");
+38 -4
src/play.rs
··· 4 4 use hyper::header::HeaderValue; 5 5 6 6 use crate::{ 7 - app::{App, State}, 7 + app::{App, CurrentDisplayMode, State, Volume}, 8 8 cfg::{SourceOptions, UiOptions}, 9 9 decoder::Mp3Decoder, 10 10 provider::{radiobrowser::Radiobrowser, tunein::Tunein, Provider}, 11 11 tui, 12 12 }; 13 13 14 - pub async fn exec(name_or_id: &str, provider: &str) -> Result<(), Error> { 14 + pub async fn exec( 15 + name_or_id: &str, 16 + provider: &str, 17 + volume: f32, 18 + display_mode: CurrentDisplayMode, 19 + ) -> Result<(), Error> { 15 20 let _provider = provider; 16 21 let provider: Box<dyn Provider> = match provider { 17 22 "tunein" => Box::new(Tunein::new()), ··· 34 39 let now_playing = station.playing.clone().unwrap_or_default(); 35 40 36 41 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<State>(); 42 + let (sink_cmd_tx, mut sink_cmd_rx) = tokio::sync::mpsc::unbounded_channel::<SinkCommand>(); 37 43 let (frame_tx, frame_rx) = std::sync::mpsc::channel::<minimp3::Frame>(); 38 44 39 45 let ui = UiOptions { ··· 51 57 tune: None, 52 58 }; 53 59 54 - let mut app = App::new(&ui, &opts, frame_rx); 60 + let mut app = App::new(&ui, &opts, frame_rx, display_mode); 55 61 let station_name = station.name.clone(); 56 62 57 63 thread::spawn(move || { ··· 60 66 let response = client.get(stream_url).send().unwrap(); 61 67 62 68 let headers = response.headers(); 69 + let volume = Volume::new(volume, false); 70 + 63 71 cmd_tx 64 72 .send(State { 65 73 name: match headers ··· 90 98 .to_str() 91 99 .unwrap() 92 100 .to_string(), 101 + volume: volume.clone(), 93 102 }) 94 103 .unwrap(); 95 104 let location = response.headers().get("location"); ··· 108 117 109 118 let (_stream, handle) = rodio::OutputStream::try_default().unwrap(); 110 119 let sink = rodio::Sink::try_new(&handle).unwrap(); 120 + sink.set_volume(volume.volume_ratio()); 111 121 let decoder = Mp3Decoder::new(response, frame_tx).unwrap(); 112 122 sink.append(decoder); 113 123 114 124 loop { 125 + while let Ok(sink_cmd) = sink_cmd_rx.try_recv() { 126 + match sink_cmd { 127 + SinkCommand::Play => { 128 + sink.play(); 129 + } 130 + SinkCommand::Pause => { 131 + sink.pause(); 132 + } 133 + SinkCommand::SetVolume(volume) => { 134 + sink.set_volume(volume); 135 + } 136 + } 137 + } 115 138 std::thread::sleep(Duration::from_millis(10)); 116 139 } 117 140 }); 118 141 119 142 let mut terminal = tui::init()?; 120 - app.run(&mut terminal, cmd_rx, &id).await; 143 + app.run(&mut terminal, cmd_rx, sink_cmd_tx, &id).await; 121 144 tui::restore()?; 122 145 Ok(()) 123 146 } 147 + 148 + /// Command for a sink. 149 + #[derive(Debug, Clone, PartialEq)] 150 + pub enum SinkCommand { 151 + /// Play. 152 + Play, 153 + /// Pause. 154 + Pause, 155 + /// Set the volume. 156 + SetVolume(f32), 157 + }
+18 -1
src/tui.rs
··· 1 1 use std::io::{self, stderr, stdout, Stdout}; 2 2 3 - use crossterm::{execute, terminal::*}; 3 + use crossterm::{ 4 + event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}, 5 + execute, 6 + terminal::*, 7 + }; 4 8 use ratatui::prelude::*; 5 9 6 10 /// A type alias for the terminal type used in this application ··· 10 14 pub fn init() -> io::Result<Tui> { 11 15 execute!(stdout(), EnterAlternateScreen)?; 12 16 execute!(stderr(), EnterAlternateScreen)?; 17 + if let Ok(true) = crossterm::terminal::supports_keyboard_enhancement() { 18 + execute!( 19 + stdout(), 20 + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) 21 + )?; 22 + } 23 + 13 24 enable_raw_mode()?; 25 + 14 26 Terminal::new(CrosstermBackend::new(stdout())) 15 27 } 16 28 ··· 18 30 pub fn restore() -> io::Result<()> { 19 31 execute!(stdout(), LeaveAlternateScreen)?; 20 32 execute!(stderr(), LeaveAlternateScreen)?; 33 + if let Ok(true) = crossterm::terminal::supports_keyboard_enhancement() { 34 + execute!(stdout(), PopKeyboardEnhancementFlags)?; 35 + } 36 + 21 37 disable_raw_mode()?; 38 + 22 39 Ok(()) 23 40 }