Browse and listen to thousands of radio stations across the globe right from your terminal ๐ŸŒŽ ๐Ÿ“ป ๐ŸŽตโœจ
radio rust tokio web-radio command-line-tool tui
at main 45 kB view raw
1use std::thread; 2use std::time::{Duration, Instant}; 3 4use anyhow::{anyhow, Error}; 5use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; 6use ratatui::layout::{Constraint, Direction, Layout}; 7use ratatui::prelude::*; 8use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; 9use tokio::sync::mpsc; 10use tunein_cli::os_media_controls::{self, OsMediaControls}; 11 12use crate::app::send_os_media_controls_command; 13use crate::audio::{AudioController, PlaybackEvent, PlaybackState}; 14use crate::extract::get_currently_playing; 15use crate::favorites::{FavoriteStation, FavoritesStore}; 16use crate::provider::{radiobrowser::Radiobrowser, tunein::Tunein, Provider}; 17use crate::tui; 18use crate::types::Station; 19 20const MENU_OPTIONS: &[&str] = &[ 21 "Search Stations", 22 "Browse Categories", 23 "Play Station", 24 "Favourites", 25 "Resume Last Station", 26 "Quit", 27]; 28 29const STATUS_TIMEOUT: Duration = Duration::from_secs(3); 30const NOW_PLAYING_POLL_INTERVAL: Duration = Duration::from_secs(10); 31 32enum HubMessage { 33 NowPlaying(String), 34} 35 36pub async fn run(provider_name: &str) -> Result<(), Error> { 37 let provider = resolve_provider(provider_name).await?; 38 let (audio, mut audio_events) = AudioController::new()?; 39 let favorites = FavoritesStore::load()?; 40 let (metadata_tx, mut metadata_rx) = mpsc::unbounded_channel::<HubMessage>(); 41 42 let mut terminal = tui::init()?; 43 44 let (input_tx, mut input_rx) = mpsc::unbounded_channel(); 45 spawn_input_thread(input_tx.clone()); 46 47 let os_media_controls = OsMediaControls::new() 48 .inspect_err(|err| { 49 eprintln!( 50 "error: failed to initialize os media controls due to `{}`", 51 err 52 ); 53 }) 54 .ok(); 55 56 let mut app = HubApp::new( 57 provider_name.to_string(), 58 provider, 59 audio, 60 favorites, 61 metadata_tx, 62 os_media_controls, 63 ); 64 65 let result = loop { 66 terminal.draw(|frame| app.render(frame))?; 67 68 tokio::select! { 69 Some(event) = input_rx.recv() => { 70 match app.handle_event(event).await? { 71 Action::Quit => break Ok(()), 72 Action::Task(task) => app.perform_task(task).await?, 73 Action::None => {} 74 } 75 } 76 Some(event) = audio_events.recv() => { 77 app.handle_playback_event(event); 78 } 79 Some(message) = metadata_rx.recv() => { 80 app.handle_metadata(message); 81 } 82 } 83 84 app.tick(); 85 }; 86 87 tui::restore()?; 88 89 result 90} 91 92fn spawn_input_thread(tx: mpsc::UnboundedSender<Event>) { 93 thread::spawn(move || loop { 94 if crossterm::event::poll(Duration::from_millis(100)).unwrap_or(false) { 95 if let Ok(event) = crossterm::event::read() { 96 if tx.send(event).is_err() { 97 break; 98 } 99 } 100 } 101 }); 102} 103 104struct HubApp { 105 provider_name: String, 106 provider: Box<dyn Provider>, 107 audio: AudioController, 108 favorites: FavoritesStore, 109 ui: UiState, 110 current_station: Option<StationRecord>, 111 current_playback: Option<PlaybackState>, 112 last_station: Option<StationRecord>, 113 volume: f32, 114 status: Option<StatusMessage>, 115 metadata_tx: mpsc::UnboundedSender<HubMessage>, 116 now_playing_station_id: Option<String>, 117 next_now_playing_poll: Instant, 118 os_media_controls: Option<OsMediaControls>, 119} 120 121impl HubApp { 122 fn new( 123 provider_name: String, 124 provider: Box<dyn Provider>, 125 audio: AudioController, 126 favorites: FavoritesStore, 127 metadata_tx: mpsc::UnboundedSender<HubMessage>, 128 os_media_controls: Option<OsMediaControls>, 129 ) -> Self { 130 let mut ui = UiState::default(); 131 ui.menu_state.select(Some(0)); 132 Self { 133 provider_name, 134 provider, 135 audio, 136 favorites, 137 ui, 138 current_station: None, 139 current_playback: None, 140 last_station: None, 141 volume: 100.0, 142 status: None, 143 metadata_tx, 144 now_playing_station_id: None, 145 next_now_playing_poll: Instant::now(), 146 os_media_controls, 147 } 148 } 149 150 fn render(&mut self, frame: &mut Frame) { 151 let areas = Layout::default() 152 .direction(Direction::Vertical) 153 .constraints( 154 [ 155 Constraint::Length(8), 156 Constraint::Length(1), 157 Constraint::Min(0), 158 Constraint::Length(1), 159 ] 160 .as_ref(), 161 ) 162 .split(frame.size()); 163 164 self.render_header(frame, areas[0]); 165 self.render_divider(frame, areas[1]); 166 self.render_main(frame, areas[2]); 167 frame.render_widget(self.render_footer(), areas[3]); 168 } 169 170 fn render_header(&self, frame: &mut Frame, area: Rect) { 171 frame.render_widget( 172 Block::new() 173 .borders(Borders::TOP) 174 .title(" TuneIn CLI ") 175 .title_alignment(Alignment::Center), 176 Rect { 177 x: area.x, 178 y: area.y, 179 width: area.width, 180 height: 1, 181 }, 182 ); 183 184 let mut row = area.y + 1; 185 186 frame.render_widget( 187 Paragraph::new(format!("Provider {}", self.provider_name)), 188 Rect { 189 x: area.x, 190 y: row, 191 width: area.width, 192 height: 1, 193 }, 194 ); 195 row += 1; 196 197 let station_name = self 198 .current_playback 199 .as_ref() 200 .and_then(|p| { 201 let name = p.stream_name.trim(); 202 if name.is_empty() || name.eq_ignore_ascii_case("unknown") { 203 let fallback = p.station.name.trim(); 204 if fallback.is_empty() { 205 None 206 } else { 207 Some(fallback.to_string()) 208 } 209 } else { 210 Some(name.to_string()) 211 } 212 }) 213 .or_else(|| { 214 self.current_station.as_ref().and_then(|s| { 215 let name = s.station.name.trim(); 216 (!name.is_empty()).then_some(name.to_string()) 217 }) 218 }) 219 .unwrap_or_else(|| "Unknown".to_string()); 220 let station_id = self 221 .current_playback 222 .as_ref() 223 .map(|p| p.station.id.as_str()) 224 .or_else(|| self.current_station.as_ref().map(|s| s.station.id.as_str())) 225 .unwrap_or("N/A"); 226 227 self.render_labeled_line( 228 frame, 229 area, 230 row, 231 "Station ", 232 &format!("{} - {}", station_name, station_id), 233 ); 234 row += 1; 235 236 let now_playing = self 237 .current_playback 238 .as_ref() 239 .and_then(|p| { 240 let np = p.now_playing.trim(); 241 (!np.is_empty()).then_some(np.to_string()) 242 }) 243 .or_else(|| { 244 self.current_station 245 .as_ref() 246 .and_then(|s| s.station.playing.as_ref()) 247 .map(|s| s.trim().to_string()) 248 .filter(|s| !s.is_empty()) 249 }) 250 .unwrap_or_else(|| "โ€”".to_string()); 251 self.render_labeled_line(frame, area, row, "Now Playing ", &now_playing); 252 row += 1; 253 254 let genre = self 255 .current_playback 256 .as_ref() 257 .and_then(|p| { 258 let genre = p.genre.trim(); 259 (!genre.is_empty()).then_some(genre.to_string()) 260 }) 261 .unwrap_or_else(|| "Unknown".to_string()); 262 self.render_labeled_line(frame, area, row, "Genre ", &genre); 263 row += 1; 264 265 let description = self 266 .current_playback 267 .as_ref() 268 .and_then(|p| { 269 let desc = p.description.trim(); 270 (!desc.is_empty()).then_some(desc.to_string()) 271 }) 272 .unwrap_or_else(|| "Unknown".to_string()); 273 self.render_labeled_line(frame, area, row, "Description ", &description); 274 row += 1; 275 276 let bitrate = self 277 .current_playback 278 .as_ref() 279 .and_then(|p| { 280 let br = p.bitrate.trim(); 281 (!br.is_empty()).then_some(format!("{} kbps", br)) 282 }) 283 .or_else(|| { 284 self.current_station.as_ref().and_then(|s| { 285 (s.station.bitrate > 0).then_some(format!("{} kbps", s.station.bitrate)) 286 }) 287 }) 288 .unwrap_or_else(|| "Unknown".to_string()); 289 self.render_labeled_line(frame, area, row, "Bitrate ", &bitrate); 290 row += 1; 291 292 let volume_display = format!("{}%", self.volume as u32); 293 self.render_labeled_line(frame, area, row, "Volume ", &volume_display); 294 } 295 296 fn render_labeled_line(&self, frame: &mut Frame, area: Rect, y: u16, label: &str, value: &str) { 297 let span_label = Span::styled(label, Style::default().fg(Color::LightBlue)); 298 let span_value = Span::raw(value); 299 let line = Line::from(vec![span_label, span_value]); 300 frame.render_widget( 301 Paragraph::new(line), 302 Rect { 303 x: area.x, 304 y, 305 width: area.width, 306 height: 1, 307 }, 308 ); 309 } 310 311 fn render_main(&mut self, frame: &mut Frame, area: Rect) { 312 if matches!(self.ui.screen, Screen::Menu) { 313 self.render_menu_area(frame, area); 314 return; 315 } 316 317 let sections = Layout::default() 318 .direction(Direction::Vertical) 319 .constraints( 320 [ 321 Constraint::Min(0), 322 Constraint::Length(1), 323 Constraint::Length(5), 324 ] 325 .as_ref(), 326 ) 327 .split(area); 328 329 self.render_non_menu_content(frame, sections[0]); 330 self.render_divider(frame, sections[1]); 331 self.render_feature_panel(frame, sections[2]); 332 } 333 334 fn render_non_menu_content(&mut self, frame: &mut Frame, area: Rect) { 335 match &mut self.ui.screen { 336 Screen::Menu => {} 337 Screen::SearchInput => { 338 let text = format!( 339 "Search query: {}\n\nPress Enter to submit, Esc to cancel", 340 self.ui.search_input 341 ); 342 let paragraph = Paragraph::new(text) 343 .block(Block::default().title("Search").borders(Borders::ALL)); 344 frame.render_widget(paragraph, area); 345 } 346 Screen::PlayInput => { 347 let text = format!( 348 "Station name or ID: {}\n\nPress Enter to submit, Esc to cancel", 349 self.ui.play_input 350 ); 351 let paragraph = Paragraph::new(text) 352 .block(Block::default().title("Play Station").borders(Borders::ALL)); 353 frame.render_widget(paragraph, area); 354 } 355 Screen::SearchResults => { 356 let items = Self::station_items(&self.ui.search_results); 357 let list = List::new(items) 358 .block( 359 Block::default() 360 .title(String::from("Search Results")) 361 .borders(Borders::ALL), 362 ) 363 .highlight_symbol("โžœ ") 364 .highlight_style( 365 Style::default() 366 .fg(Color::Yellow) 367 .add_modifier(Modifier::BOLD), 368 ); 369 frame.render_stateful_widget(list, area, &mut self.ui.search_results_state); 370 } 371 Screen::Categories => { 372 let items = Self::category_items(&self.ui.categories); 373 let list = List::new(items) 374 .block(Block::default().title("Categories").borders(Borders::ALL)) 375 .highlight_symbol("โžœ ") 376 .highlight_style( 377 Style::default() 378 .fg(Color::Yellow) 379 .add_modifier(Modifier::BOLD), 380 ); 381 frame.render_stateful_widget(list, area, &mut self.ui.categories_state); 382 } 383 Screen::BrowseStations { category } => { 384 let items = Self::station_items(&self.ui.browse_results); 385 let list = List::new(items) 386 .block( 387 Block::default() 388 .title(format!("Stations in {}", category)) 389 .borders(Borders::ALL), 390 ) 391 .highlight_symbol("โžœ ") 392 .highlight_style( 393 Style::default() 394 .fg(Color::Yellow) 395 .add_modifier(Modifier::BOLD), 396 ); 397 frame.render_stateful_widget(list, area, &mut self.ui.browse_state); 398 } 399 Screen::Favourites => { 400 let items = Self::favourite_items(self.favorites.all()); 401 let list = List::new(items) 402 .block(Block::default().title("Favourites").borders(Borders::ALL)) 403 .highlight_symbol("โžœ ") 404 .highlight_style( 405 Style::default() 406 .fg(Color::Yellow) 407 .add_modifier(Modifier::BOLD), 408 ); 409 frame.render_stateful_widget(list, area, &mut self.ui.favourites_state); 410 } 411 Screen::Loading => { 412 let message = self 413 .ui 414 .loading_message 415 .as_deref() 416 .unwrap_or("Loading, please waitโ€ฆ"); 417 let paragraph = Paragraph::new(message) 418 .block(Block::default().title("Loading").borders(Borders::ALL)) 419 .alignment(Alignment::Center); 420 frame.render_widget(paragraph, area); 421 } 422 } 423 } 424 425 fn render_divider(&self, frame: &mut Frame, area: Rect) { 426 if area.width == 0 || area.height == 0 { 427 return; 428 } 429 let width = area.width as usize; 430 if width == 0 { 431 return; 432 } 433 let mut line = String::with_capacity(width + 3); 434 while line.len() < width { 435 line.push_str("---"); 436 } 437 line.truncate(width); 438 frame.render_widget(Paragraph::new(line), area); 439 } 440 441 fn render_feature_panel(&self, frame: &mut Frame, area: Rect) { 442 if area.height == 0 || area.width == 0 { 443 return; 444 } 445 446 let lines = self.feature_panel_lines(); 447 let text = lines.join("\n"); 448 let paragraph = 449 Paragraph::new(text).block(Block::default().title("Actions").borders(Borders::ALL)); 450 frame.render_widget(paragraph, area); 451 } 452 453 fn render_menu_area(&mut self, frame: &mut Frame, area: Rect) { 454 if area.height == 0 || area.width == 0 { 455 return; 456 } 457 let disable_resume = self.last_station.is_none(); 458 let items: Vec<ListItem> = MENU_OPTIONS 459 .iter() 460 .map(|option| { 461 if *option == "Resume Last Station" && disable_resume { 462 ListItem::new(Line::from(Span::styled( 463 *option, 464 Style::default().fg(Color::DarkGray), 465 ))) 466 } else { 467 ListItem::new(*option) 468 } 469 }) 470 .collect(); 471 let list = List::new(items) 472 .block(Block::default().borders(Borders::ALL).title("Main Menu")) 473 .highlight_style( 474 Style::default() 475 .fg(Color::Yellow) 476 .add_modifier(Modifier::BOLD), 477 ) 478 .highlight_symbol("โžœ "); 479 frame.render_stateful_widget(list, area, &mut self.ui.menu_state); 480 } 481 482 fn station_items(stations: &[Station]) -> Vec<ListItem<'_>> { 483 if stations.is_empty() { 484 vec![ListItem::new("No stations found")] 485 } else { 486 stations 487 .iter() 488 .map(|station| { 489 let mut line = station.name.clone(); 490 if let Some(now) = &station.playing { 491 if !now.is_empty() { 492 line.push_str(&format!(" โ€” {}", now)); 493 } 494 } 495 ListItem::new(line) 496 }) 497 .collect() 498 } 499 } 500 501 fn category_items(categories: &[String]) -> Vec<ListItem<'_>> { 502 if categories.is_empty() { 503 vec![ListItem::new("No categories available")] 504 } else { 505 categories 506 .iter() 507 .map(|category| ListItem::new(category.clone())) 508 .collect() 509 } 510 } 511 512 fn favourite_items(favourites: &[FavoriteStation]) -> Vec<ListItem<'_>> { 513 if favourites.is_empty() { 514 vec![ListItem::new("No favourites saved yet")] 515 } else { 516 favourites 517 .iter() 518 .map(|fav| ListItem::new(format!("{} ({})", fav.name, fav.provider))) 519 .collect() 520 } 521 } 522 523 fn handle_favourite_action(&mut self) -> Result<bool, Error> { 524 match self.ui.screen { 525 Screen::SearchResults => { 526 let Some(index) = self.ui.search_results_state.selected() else { 527 self.set_status("No search result selected"); 528 return Ok(true); 529 }; 530 let station = self 531 .ui 532 .search_results 533 .get(index) 534 .cloned() 535 .ok_or_else(|| anyhow!("Search result missing at index {}", index))?; 536 self.add_station_to_favourites(station)?; 537 Ok(true) 538 } 539 Screen::BrowseStations { .. } => { 540 let Some(index) = self.ui.browse_state.selected() else { 541 self.set_status("No station selected"); 542 return Ok(true); 543 }; 544 let station = self 545 .ui 546 .browse_results 547 .get(index) 548 .cloned() 549 .ok_or_else(|| anyhow!("Browse result missing at index {}", index))?; 550 self.add_station_to_favourites(station)?; 551 Ok(true) 552 } 553 Screen::Favourites => { 554 let Some(index) = self.ui.favourites_state.selected() else { 555 self.set_status("No favourite selected"); 556 return Ok(true); 557 }; 558 self.remove_favourite_at(index)?; 559 Ok(true) 560 } 561 _ => { 562 self.toggle_current_favourite()?; 563 Ok(true) 564 } 565 } 566 } 567 568 fn add_station_to_favourites(&mut self, station: Station) -> Result<(), Error> { 569 if station.id.is_empty() { 570 self.set_status("Cannot favourite station without an id"); 571 return Ok(()); 572 } 573 574 let entry = FavoriteStation { 575 id: station.id.clone(), 576 name: station.name.clone(), 577 provider: self.provider_name.clone(), 578 }; 579 580 if self.favorites.is_favorite(&entry.id, &entry.provider) { 581 self.set_status("Already in favourites"); 582 } else { 583 self.favorites.add(entry)?; 584 self.set_status(&format!("Added \"{}\" to favourites", station.name)); 585 } 586 Ok(()) 587 } 588 589 fn remove_favourite_at(&mut self, index: usize) -> Result<(), Error> { 590 let Some(favourite) = self.favorites.all().get(index).cloned() else { 591 self.set_status("Favourite not found"); 592 return Ok(()); 593 }; 594 self.favorites.remove(&favourite.id, &favourite.provider)?; 595 self.set_status(&format!("Removed \"{}\" from favourites", favourite.name)); 596 597 let len = self.favorites.all().len(); 598 if len == 0 { 599 self.ui.favourites_state.select(None); 600 } else { 601 let new_index = index.min(len - 1); 602 self.ui.favourites_state.select(Some(new_index)); 603 } 604 605 Ok(()) 606 } 607 608 fn stop_playback(&mut self) -> Result<(), Error> { 609 self.audio.stop()?; 610 self.set_status("Playback stopped"); 611 Ok(()) 612 } 613 614 fn default_footer_hint(&self) -> String { 615 match self.ui.screen { 616 Screen::SearchResults => { 617 "โ†‘/โ†“ navigate โ€ข Enter play โ€ข f add to favourites โ€ข x stop playback โ€ข Esc back โ€ข +/- volume" 618 .to_string() 619 } 620 Screen::Favourites => { 621 "โ†‘/โ†“ navigate โ€ข Enter play โ€ข f remove favourite โ€ข d/Delete remove โ€ข x stop playback โ€ข Esc back โ€ข +/- volume" 622 .to_string() 623 } 624 Screen::Categories => { 625 "โ†‘/โ†“ navigate โ€ข Enter open โ€ข x stop playback โ€ข Esc back โ€ข +/- volume".to_string() 626 } 627 Screen::BrowseStations { .. } => { 628 "โ†‘/โ†“ navigate โ€ข Enter play โ€ข f add to favourites โ€ข x stop playback โ€ข Esc back โ€ข +/- volume".to_string() 629 } 630 Screen::SearchInput | Screen::PlayInput => { 631 "Type to edit โ€ข Enter submit โ€ข x stop playback โ€ข Esc cancel โ€ข +/- volume".to_string() 632 } 633 Screen::Loading => "Please waitโ€ฆ โ€ข x stop playback โ€ข Esc cancel โ€ข +/- volume".to_string(), 634 Screen::Menu => { 635 "โ†‘/โ†“ navigate โ€ข Enter select โ€ข x stop playback โ€ข Esc back โ€ข +/- volume".to_string() 636 } 637 } 638 } 639 640 fn feature_panel_lines(&self) -> Vec<String> { 641 let mut lines = match self.ui.screen { 642 Screen::SearchResults => vec![ 643 "Search Results".to_string(), 644 "Enter โ€ข Play highlighted station".to_string(), 645 "f โ€ข Add highlighted station to favourites".to_string(), 646 "Esc โ€ข Return to main menu".to_string(), 647 ], 648 Screen::Favourites => vec![ 649 "Favourites".to_string(), 650 "Enter โ€ข Play selected favourite".to_string(), 651 "f โ€ข Remove highlighted favourite".to_string(), 652 "d/Del โ€ข Remove highlighted favourite".to_string(), 653 "Esc โ€ข Return to main menu".to_string(), 654 ], 655 Screen::BrowseStations { .. } => vec![ 656 "Browse Stations".to_string(), 657 "Enter โ€ข Play highlighted station".to_string(), 658 "f โ€ข Add highlighted station to favourites".to_string(), 659 "Esc โ€ข Back to categories".to_string(), 660 ], 661 Screen::Categories => vec![ 662 "Categories".to_string(), 663 "Enter โ€ข Drill into selected category".to_string(), 664 "Esc โ€ข Return to main menu".to_string(), 665 ], 666 Screen::SearchInput => vec![ 667 "Search".to_string(), 668 "Enter โ€ข Run search".to_string(), 669 "Esc โ€ข Cancel".to_string(), 670 ], 671 Screen::PlayInput => vec![ 672 "Play Station".to_string(), 673 "Enter โ€ข Start playback".to_string(), 674 "Esc โ€ข Cancel".to_string(), 675 ], 676 Screen::Loading => vec!["Loadingโ€ฆ".to_string(), "Esc โ€ข Cancel".to_string()], 677 Screen::Menu => vec![ 678 "Main Menu".to_string(), 679 "Enter โ€ข Activate highlighted option".to_string(), 680 "Esc โ€ข Quit or back".to_string(), 681 ], 682 }; 683 684 if self.current_station.is_some() { 685 lines.insert(1, "x โ€ข Stop playback".to_string()); 686 } else { 687 lines.insert(1, "x โ€ข Stop playback (no active stream)".to_string()); 688 } 689 690 lines 691 } 692 693 fn render_footer(&self) -> Paragraph<'_> { 694 let hint = self.default_footer_hint(); 695 let text = if let Some(status) = &self.status { 696 format!("{} โ€ข {}", status.message, hint) 697 } else { 698 hint 699 }; 700 Paragraph::new(text) 701 } 702 703 async fn handle_event(&mut self, event: Event) -> Result<Action, Error> { 704 match event { 705 Event::Key(key) => self.handle_key_event(key).await, 706 Event::Resize(_, _) => Ok(Action::None), 707 _ => Ok(Action::None), 708 } 709 } 710 711 async fn handle_key_event(&mut self, key: KeyEvent) -> Result<Action, Error> { 712 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { 713 return Ok(Action::Quit); 714 } 715 716 match key.code { 717 KeyCode::Char('+') | KeyCode::Char('=') => { 718 self.adjust_volume(5.0)?; 719 return Ok(Action::None); 720 } 721 KeyCode::Char('-') => { 722 self.adjust_volume(-5.0)?; 723 return Ok(Action::None); 724 } 725 KeyCode::Char('x') => { 726 self.stop_playback()?; 727 return Ok(Action::None); 728 } 729 KeyCode::Char('f') => { 730 if self.handle_favourite_action()? { 731 return Ok(Action::None); 732 } 733 } 734 KeyCode::Esc if !matches!(self.ui.screen, Screen::Menu) => { 735 self.ui.screen = Screen::Menu; 736 return Ok(Action::None); 737 } 738 _ => {} 739 } 740 741 match self.ui.screen { 742 Screen::Menu => self.handle_menu_keys(key), 743 Screen::SearchInput => self.handle_text_input(key, true), 744 Screen::PlayInput => self.handle_text_input(key, false), 745 Screen::SearchResults => self.handle_station_list_keys(key, ListKind::Search), 746 Screen::Categories => self.handle_categories_keys(key), 747 Screen::BrowseStations { .. } => self.handle_station_list_keys(key, ListKind::Browse), 748 Screen::Favourites => self.handle_favourites_keys(key), 749 Screen::Loading => Ok(Action::None), 750 } 751 } 752 753 fn handle_menu_keys(&mut self, key: KeyEvent) -> Result<Action, Error> { 754 let current = self.ui.menu_state.selected().unwrap_or(0); 755 match key.code { 756 KeyCode::Up => { 757 let new = current.saturating_sub(1); 758 self.ui.menu_state.select(Some(new)); 759 Ok(Action::None) 760 } 761 KeyCode::Down => { 762 let max = MENU_OPTIONS.len().saturating_sub(1); 763 let new = (current + 1).min(max); 764 self.ui.menu_state.select(Some(new)); 765 Ok(Action::None) 766 } 767 KeyCode::Enter => match MENU_OPTIONS[current] { 768 "Search Stations" => { 769 self.ui.search_input.clear(); 770 self.ui.screen = Screen::SearchInput; 771 Ok(Action::None) 772 } 773 "Browse Categories" => { 774 self.ui.loading_message = Some("Fetching categoriesโ€ฆ".to_string()); 775 self.ui.screen = Screen::Loading; 776 Ok(Action::Task(PendingTask::LoadCategories)) 777 } 778 "Play Station" => { 779 self.ui.play_input.clear(); 780 self.ui.screen = Screen::PlayInput; 781 Ok(Action::None) 782 } 783 "Favourites" => { 784 self.ui.screen = Screen::Favourites; 785 if self.favorites.all().is_empty() { 786 self.ui.favourites_state.select(None); 787 } else { 788 self.ui.favourites_state.select(Some(0)); 789 } 790 Ok(Action::None) 791 } 792 "Resume Last Station" => { 793 if let Some(station) = self.last_station.clone() { 794 Ok(Action::Task(PendingTask::PlayStation(station))) 795 } else { 796 self.set_status("No station played yet to resume"); 797 Ok(Action::None) 798 } 799 } 800 "Quit" => Ok(Action::Quit), 801 _ => Ok(Action::None), 802 }, 803 _ => Ok(Action::None), 804 } 805 } 806 807 fn handle_text_input(&mut self, key: KeyEvent, is_search: bool) -> Result<Action, Error> { 808 let buffer = if is_search { 809 &mut self.ui.search_input 810 } else { 811 &mut self.ui.play_input 812 }; 813 814 match key.code { 815 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { 816 buffer.push(c); 817 Ok(Action::None) 818 } 819 KeyCode::Backspace => { 820 buffer.pop(); 821 Ok(Action::None) 822 } 823 KeyCode::Enter => { 824 if buffer.trim().is_empty() { 825 self.set_status("Input cannot be empty"); 826 return Ok(Action::None); 827 } 828 let query = buffer.trim().to_string(); 829 self.ui.loading_message = Some("Searching stationsโ€ฆ".to_string()); 830 self.ui.screen = Screen::Loading; 831 if is_search { 832 Ok(Action::Task(PendingTask::Search(query))) 833 } else { 834 Ok(Action::Task(PendingTask::PlayDirect(query))) 835 } 836 } 837 _ => Ok(Action::None), 838 } 839 } 840 841 fn handle_station_list_keys(&mut self, key: KeyEvent, kind: ListKind) -> Result<Action, Error> { 842 let (items_len, state) = match kind { 843 ListKind::Search => ( 844 self.ui.search_results.len(), 845 &mut self.ui.search_results_state, 846 ), 847 ListKind::Browse => (self.ui.browse_results.len(), &mut self.ui.browse_state), 848 }; 849 850 if items_len == 0 { 851 if key.code == KeyCode::Esc { 852 self.ui.screen = Screen::Menu; 853 } 854 return Ok(Action::None); 855 } 856 857 let current = state.selected().unwrap_or(0); 858 match key.code { 859 KeyCode::Up => { 860 let new = current.saturating_sub(1); 861 state.select(Some(new)); 862 Ok(Action::None) 863 } 864 KeyCode::Down => { 865 let max = items_len.saturating_sub(1); 866 let new = (current + 1).min(max); 867 state.select(Some(new)); 868 Ok(Action::None) 869 } 870 KeyCode::Enter => { 871 let station = match kind { 872 ListKind::Search => self.ui.search_results[current].clone(), 873 ListKind::Browse => self.ui.browse_results[current].clone(), 874 }; 875 Ok(Action::Task(PendingTask::PlayStation(StationRecord { 876 provider: self.provider_name.clone(), 877 station, 878 }))) 879 } 880 KeyCode::Esc => { 881 self.ui.screen = Screen::Menu; 882 Ok(Action::None) 883 } 884 _ => Ok(Action::None), 885 } 886 } 887 888 fn handle_categories_keys(&mut self, key: KeyEvent) -> Result<Action, Error> { 889 let len = self.ui.categories.len(); 890 if len == 0 { 891 if key.code == KeyCode::Esc { 892 self.ui.screen = Screen::Menu; 893 } 894 return Ok(Action::None); 895 } 896 897 let current = self.ui.categories_state.selected().unwrap_or(0); 898 match key.code { 899 KeyCode::Up => { 900 let new = current.saturating_sub(1); 901 self.ui.categories_state.select(Some(new)); 902 Ok(Action::None) 903 } 904 KeyCode::Down => { 905 let max = len.saturating_sub(1); 906 let new = (current + 1).min(max); 907 self.ui.categories_state.select(Some(new)); 908 Ok(Action::None) 909 } 910 KeyCode::Enter => { 911 let category = self.ui.categories[current].clone(); 912 self.ui.loading_message = Some(format!("Loading stations for {}โ€ฆ", category)); 913 self.ui.screen = Screen::Loading; 914 Ok(Action::Task(PendingTask::LoadCategoryStations { category })) 915 } 916 KeyCode::Esc => { 917 self.ui.screen = Screen::Menu; 918 Ok(Action::None) 919 } 920 _ => Ok(Action::None), 921 } 922 } 923 924 fn handle_favourites_keys(&mut self, key: KeyEvent) -> Result<Action, Error> { 925 let len = self.favorites.all().len(); 926 if len == 0 { 927 if key.code == KeyCode::Esc { 928 self.ui.screen = Screen::Menu; 929 } 930 return Ok(Action::None); 931 } 932 933 let current = self.ui.favourites_state.selected().unwrap_or(0); 934 match key.code { 935 KeyCode::Up => { 936 let new = current.saturating_sub(1); 937 self.ui.favourites_state.select(Some(new)); 938 Ok(Action::None) 939 } 940 KeyCode::Down => { 941 let max = len.saturating_sub(1); 942 let new = (current + 1).min(max); 943 self.ui.favourites_state.select(Some(new)); 944 Ok(Action::None) 945 } 946 KeyCode::Enter => { 947 let favourite = self.favorites.all()[current].clone(); 948 Ok(Action::Task(PendingTask::PlayFavourite(favourite))) 949 } 950 KeyCode::Delete | KeyCode::Char('d') | KeyCode::Char('f') => { 951 self.remove_favourite_at(current)?; 952 Ok(Action::None) 953 } 954 KeyCode::Esc => { 955 self.ui.screen = Screen::Menu; 956 Ok(Action::None) 957 } 958 _ => Ok(Action::None), 959 } 960 } 961 962 fn adjust_volume(&mut self, delta: f32) -> Result<(), Error> { 963 self.volume = (self.volume + delta).clamp(0.0, 150.0); 964 self.audio.set_volume(self.volume)?; 965 self.set_status(&format!("Volume set to {}%", self.volume as u32)); 966 Ok(()) 967 } 968 969 fn toggle_current_favourite(&mut self) -> Result<(), Error> { 970 let Some(station) = &self.current_station else { 971 self.set_status("No active station to favourite"); 972 return Ok(()); 973 }; 974 975 if station.station.id.is_empty() { 976 self.set_status("Current station cannot be favourited"); 977 return Ok(()); 978 } 979 980 let entry = FavoriteStation { 981 id: station.station.id.clone(), 982 name: station.station.name.clone(), 983 provider: station.provider.clone(), 984 }; 985 let added = self.favorites.toggle(entry)?; 986 if added { 987 self.set_status("Added to favourites"); 988 } else { 989 self.set_status("Removed from favourites"); 990 } 991 Ok(()) 992 } 993 994 fn handle_playback_event(&mut self, event: PlaybackEvent) { 995 match event { 996 PlaybackEvent::Started(state) => { 997 self.current_playback = Some(state.clone()); 998 if let Some(station) = self.current_station.as_mut() { 999 station.station.playing = Some(state.now_playing.clone()); 1000 station.station.id = state.station.id.clone(); 1001 } 1002 self.set_status(&format!("Now playing {}", state.stream_name)); 1003 self.prepare_now_playing_poll(); 1004 } 1005 PlaybackEvent::Error(err) => { 1006 self.current_playback = None; 1007 self.set_status(&format!("Playback error: {}", err)); 1008 self.now_playing_station_id = None; 1009 } 1010 PlaybackEvent::Stopped => { 1011 self.current_playback = None; 1012 self.set_status("Playback stopped"); 1013 self.now_playing_station_id = None; 1014 } 1015 } 1016 } 1017 1018 fn handle_metadata(&mut self, message: HubMessage) { 1019 match message { 1020 HubMessage::NowPlaying(now_playing) => { 1021 if let Some(playback) = self.current_playback.as_mut() { 1022 playback.now_playing = now_playing.clone(); 1023 } 1024 if let Some(station) = self.current_station.as_mut() { 1025 station.station.playing = Some(now_playing.clone()); 1026 } 1027 self.set_status(&format!("Now Playing {}", now_playing)); 1028 1029 let name = self 1030 .current_station 1031 .as_ref() 1032 .map(|s| s.station.name.clone()) 1033 .unwrap_or_default(); 1034 1035 send_os_media_controls_command( 1036 self.os_media_controls.as_mut(), 1037 os_media_controls::Command::SetMetadata(souvlaki::MediaMetadata { 1038 title: (!now_playing.is_empty()).then_some(now_playing.as_str()), 1039 album: (!name.is_empty()).then_some(name.as_str()), 1040 artist: None, 1041 cover_url: None, 1042 duration: None, 1043 }), 1044 ); 1045 } 1046 } 1047 } 1048 1049 async fn perform_task(&mut self, task: PendingTask) -> Result<(), Error> { 1050 self.ui.loading_message = None; 1051 match task { 1052 PendingTask::Search(query) => { 1053 let results = self.provider.search(query.clone()).await?; 1054 self.ui.search_results = results; 1055 self.ui.search_results_state.select(Some(0)); 1056 self.ui.screen = Screen::SearchResults; 1057 self.set_status(&format!("Search complete for \"{}\"", query)); 1058 } 1059 PendingTask::LoadCategories => { 1060 let categories = self.provider.categories(0, 100).await?; 1061 self.ui.categories = categories; 1062 self.ui.categories_state.select(Some(0)); 1063 self.ui.screen = Screen::Categories; 1064 self.set_status("Categories loaded"); 1065 } 1066 PendingTask::LoadCategoryStations { category } => { 1067 let stations = self.provider.browse(category.clone(), 0, 100).await?; 1068 self.ui.browse_results = stations; 1069 self.ui.browse_state.select(Some(0)); 1070 self.ui.screen = Screen::BrowseStations { category }; 1071 self.set_status("Stations loaded"); 1072 } 1073 PendingTask::PlayDirect(input) => { 1074 let provider = resolve_provider(&self.provider_name).await?; 1075 match provider.get_station(input.clone()).await? { 1076 Some(mut station) => { 1077 if station.stream_url.is_empty() { 1078 station = fetch_station(&self.provider_name, &station.id) 1079 .await? 1080 .ok_or_else(|| anyhow!("Unable to locate stream for station"))?; 1081 } 1082 self.play_station(StationRecord { 1083 provider: self.provider_name.clone(), 1084 station, 1085 }) 1086 .await?; 1087 } 1088 None => { 1089 self.ui.screen = Screen::Menu; 1090 self.set_status(&format!("Station \"{}\" not found", input)); 1091 } 1092 } 1093 } 1094 PendingTask::PlayStation(record) => { 1095 self.play_station(record).await?; 1096 } 1097 PendingTask::PlayFavourite(favourite) => { 1098 let station = fetch_station(&favourite.provider, &favourite.id) 1099 .await? 1100 .ok_or_else(|| anyhow!("Failed to load favourite station"))?; 1101 self.play_station(StationRecord { 1102 provider: favourite.provider, 1103 station, 1104 }) 1105 .await?; 1106 } 1107 } 1108 Ok(()) 1109 } 1110 1111 async fn play_station(&mut self, mut record: StationRecord) -> Result<(), Error> { 1112 if record.station.stream_url.is_empty() { 1113 if let Some(enriched) = fetch_station(&record.provider, &record.station.id).await? { 1114 record.station = enriched; 1115 } else { 1116 return Err(anyhow!("Unable to resolve station stream")); 1117 } 1118 } 1119 1120 self.audio.play(record.station.clone(), self.volume)?; 1121 self.current_station = Some(record.clone()); 1122 self.last_station = Some(record); 1123 self.prepare_now_playing_poll(); 1124 self.ui.screen = Screen::Menu; 1125 Ok(()) 1126 } 1127 1128 fn prepare_now_playing_poll(&mut self) { 1129 if let Some(station) = &self.current_station { 1130 if station.provider == "tunein" && !station.station.id.is_empty() { 1131 self.now_playing_station_id = Some(station.station.id.clone()); 1132 self.next_now_playing_poll = Instant::now(); 1133 } else { 1134 self.now_playing_station_id = None; 1135 } 1136 } 1137 } 1138 1139 fn tick(&mut self) { 1140 if let Some(status) = &self.status { 1141 if status.expires_at <= Instant::now() { 1142 self.status = None; 1143 } 1144 } 1145 self.poll_now_playing_if_needed(); 1146 } 1147 1148 fn poll_now_playing_if_needed(&mut self) { 1149 let Some(station_id) = self.now_playing_station_id.clone() else { 1150 return; 1151 }; 1152 1153 if Instant::now() < self.next_now_playing_poll { 1154 return; 1155 } 1156 1157 let tx = self.metadata_tx.clone(); 1158 tokio::spawn(async move { 1159 if let Ok(now) = get_currently_playing(&station_id).await { 1160 let _ = tx.send(HubMessage::NowPlaying(now)); 1161 } 1162 }); 1163 1164 self.next_now_playing_poll = Instant::now() + NOW_PLAYING_POLL_INTERVAL; 1165 } 1166 1167 fn set_status<S: Into<String>>(&mut self, message: S) { 1168 self.status = Some(StatusMessage { 1169 message: message.into(), 1170 expires_at: Instant::now() + STATUS_TIMEOUT, 1171 }); 1172 } 1173} 1174 1175struct UiState { 1176 screen: Screen, 1177 menu_state: ListState, 1178 search_input: String, 1179 play_input: String, 1180 search_results: Vec<Station>, 1181 search_results_state: ListState, 1182 categories: Vec<String>, 1183 categories_state: ListState, 1184 browse_results: Vec<Station>, 1185 browse_state: ListState, 1186 favourites_state: ListState, 1187 loading_message: Option<String>, 1188} 1189 1190impl Default for UiState { 1191 fn default() -> Self { 1192 Self { 1193 screen: Screen::Menu, 1194 menu_state: ListState::default(), 1195 search_input: String::new(), 1196 play_input: String::new(), 1197 search_results: Vec::new(), 1198 search_results_state: ListState::default(), 1199 categories: Vec::new(), 1200 categories_state: ListState::default(), 1201 browse_results: Vec::new(), 1202 browse_state: ListState::default(), 1203 favourites_state: ListState::default(), 1204 loading_message: None, 1205 } 1206 } 1207} 1208 1209#[derive(Clone)] 1210enum Screen { 1211 Menu, 1212 SearchInput, 1213 PlayInput, 1214 SearchResults, 1215 Categories, 1216 BrowseStations { category: String }, 1217 Favourites, 1218 Loading, 1219} 1220 1221enum ListKind { 1222 Search, 1223 Browse, 1224} 1225 1226enum PendingTask { 1227 Search(String), 1228 LoadCategories, 1229 LoadCategoryStations { category: String }, 1230 PlayDirect(String), 1231 PlayStation(StationRecord), 1232 PlayFavourite(FavoriteStation), 1233} 1234 1235enum Action { 1236 None, 1237 Quit, 1238 Task(PendingTask), 1239} 1240 1241struct StatusMessage { 1242 message: String, 1243 expires_at: Instant, 1244} 1245 1246#[derive(Clone)] 1247struct StationRecord { 1248 provider: String, 1249 station: Station, 1250} 1251 1252async fn resolve_provider(name: &str) -> Result<Box<dyn Provider>, Error> { 1253 match name { 1254 "tunein" => Ok(Box::new(Tunein::new())), 1255 "radiobrowser" => Ok(Box::new(Radiobrowser::new().await)), 1256 other => Err(anyhow!("Unsupported provider '{}'", other)), 1257 } 1258} 1259 1260async fn fetch_station(provider_name: &str, id: &str) -> Result<Option<Station>, Error> { 1261 let provider = resolve_provider(provider_name).await?; 1262 provider.get_station(id.to_string()).await 1263}