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 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}