use crate::radio::station::StationInfo; use color_eyre::{ Result, eyre::{WrapErr, eyre}, }; use radiobrowser::{RadioBrowserAPI, StationOrder}; use ratatui::{DefaultTerminal, widgets::ListState}; use std::collections::HashSet; use tokio::process::Child; use tokio::sync::mpsc; mod draw; mod favorites; mod input; mod player; #[cfg(test)] mod tests; #[derive(Debug, PartialEq)] pub enum InputMode { Normal, Search, } #[derive(Debug)] pub struct App { exit: bool, stations: Vec, station_list_state: ListState, filtered_stations: Vec, search_query: String, input_mode: InputMode, player: Option, current_station: Option, current_song: Option, played_songs: Vec, song_rx: Option>, error_message: Option, spawn_player: Option, favorites: HashSet, show_favorites_only: bool, } impl Default for App { fn default() -> Self { Self { exit: false, stations: Vec::new(), station_list_state: { let mut state = ListState::default(); state.select(Some(0)); state }, filtered_stations: Vec::new(), input_mode: InputMode::Normal, search_query: String::new(), player: None, current_station: None, current_song: None, played_songs: Vec::new(), song_rx: None, error_message: None, spawn_player: Some(player::new_mpv_spawn()), favorites: favorites::load_favorites(), show_favorites_only: false, } } } impl App { pub fn without_player(mut self) -> Self { self.spawn_player = None; self } pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { while !self.exit { terminal.draw(|frame| self.draw(frame))?; self.handle_events() .await .wrap_err("handle events failed")?; self.check_song_updates(); } Ok(()) } fn check_song_updates(&mut self) { let mut new_songs = Vec::new(); if let Some(rx) = &mut self.song_rx { while let Ok(song) = rx.try_recv() { new_songs.push(song); } } for song in new_songs { self.update_current_song(song); } if let Some(child) = &mut self.player && let Ok(Some(_)) = child.try_wait() { self.player = None; self.error_message = Some("Stream disconnected".to_string()); } } fn update_current_song(&mut self, song: String) { if let Some(prev) = &self.current_song && prev != &song { self.played_songs.insert(0, prev.clone()); if self.played_songs.len() > 20 { self.played_songs.pop(); } } self.current_song = Some(song); } fn update_filter(&mut self) { if self.search_query.is_empty() { self.filtered_stations = self.stations.clone(); } else { let query = self.search_query.to_lowercase(); self.filtered_stations = self .stations .iter() .filter(|s| { s.name.to_lowercase().contains(&query) || s.tags.to_lowercase().contains(&query) }) .cloned() .collect(); } if self.show_favorites_only { self.filtered_stations.retain(|s| self.favorites.contains(&s.url)); } if self.filtered_stations.is_empty() { self.station_list_state.select(None); } else { self.station_list_state.select(Some(0)); } } pub async fn load_stations(&mut self) -> color_eyre::Result<()> { let api = RadioBrowserAPI::new() .await .map_err(|e| eyre!("Failed to create RadioBrowserAPI: {e}"))?; let (de, us, at) = tokio::try_join!( async { api.get_stations().country("Germany").order(StationOrder::Clickcount).reverse(true).limit("5000").send().await.map_err(|e| eyre!("Failed to fetch DE stations: {e}")) }, async { api.get_stations().country("United States").order(StationOrder::Clickcount).reverse(true).limit("5000").send().await.map_err(|e| eyre!("Failed to fetch US stations: {e}")) }, async { api.get_stations().country("Austria").order(StationOrder::Clickcount).reverse(true).limit("5000").send().await.map_err(|e| eyre!("Failed to fetch AT stations: {e}")) }, )?; let mut seen_urls = std::collections::HashSet::new(); let mut stations: Vec = de .into_iter() .chain(us) .chain(at) .filter_map(|s: radiobrowser::ApiStation| { if s.url.is_empty() || !seen_urls.insert(s.url.clone()) { return None; } Some(StationInfo { name: s.name, url: s.url, country_code: s.countrycode, bitrate: s.bitrate, codec: s.codec, tags: s.tags, }) }) .collect(); stations.sort_by_key(|a| a.name.to_lowercase()); self.stations = stations; self.update_filter(); Ok(()) } }