From 38b5e0c5578874a2a7e3c3d5b358a230f6b1bd11 Mon Sep 17 00:00:00 2001 From: mace Date: Fri, 29 May 2026 17:46:47 +0200 Subject: [PATCH] Refactoring, improve code structure --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/radio/app.rs | 928 ------------------------------------- src/radio/app/draw.rs | 186 ++++++++ src/radio/app/favorites.rs | 47 ++ src/radio/app/input.rs | 99 ++++ src/radio/app/mod.rs | 183 ++++++++ src/radio/app/player.rs | 86 ++++ src/radio/app/tests.rs | 340 ++++++++++++++ 9 files changed, 943 insertions(+), 930 deletions(-) delete mode 100644 src/radio/app.rs create mode 100644 src/radio/app/draw.rs create mode 100644 src/radio/app/favorites.rs create mode 100644 src/radio/app/input.rs create mode 100644 src/radio/app/mod.rs create mode 100644 src/radio/app/player.rs create mode 100644 src/radio/app/tests.rs diff --git a/Cargo.lock b/Cargo.lock index b6bdc5c..f57008c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1498,7 +1498,7 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iradio" -version = "0.9.0" +version = "0.9.1" dependencies = [ "color-eyre", "crossterm 0.29.0", diff --git a/Cargo.toml b/Cargo.toml index 8416e3f..f3ff174 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iradio" -version = "0.9.0" +version = "0.9.1" edition = "2024" [dependencies] diff --git a/src/radio/app.rs b/src/radio/app.rs deleted file mode 100644 index 2d9e491..0000000 --- a/src/radio/app.rs +++ /dev/null @@ -1,928 +0,0 @@ -use crate::radio::station::StationInfo; -use color_eyre::{ - Result, - eyre::{WrapErr, eyre}, -}; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use radiobrowser::{RadioBrowserAPI, StationOrder}; -use ratatui::{ - DefaultTerminal, Frame, - layout::{Constraint, Layout}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, List, ListItem, ListState, Paragraph}, -}; -use std::collections::HashSet; -use std::io; -use std::path::PathBuf; -use tokio::process::Child; -use tokio::sync::mpsc; - -fn favorites_path() -> PathBuf { - let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); - PathBuf::from(home).join(".config").join("iradio").join("favorites.json") -} - -fn load_favorites() -> HashSet { - std::fs::read_to_string(favorites_path()) - .ok() - .and_then(|s| serde_json::from_str::>(&s).ok()) - .map(|v| v.into_iter().collect()) - .unwrap_or_default() -} - -fn save_favorites(favorites: &HashSet) { - let path = favorites_path(); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - let mut list: Vec<&String> = favorites.iter().collect(); - list.sort(); - if let Ok(json) = serde_json::to_string(&list) { - let _ = std::fs::write(path, json); - } -} - -#[allow(clippy::type_complexity)] -struct SpawnFn(Box io::Result + Send + Sync>); - -impl std::fmt::Debug for SpawnFn { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("SpawnFn") - } -} - -fn mpv_spawn(url: &str) -> io::Result { - tokio::process::Command::new("mpv") - .arg(url) - .arg("--no-video") - .arg("--quiet") - .arg("--term-playing-msg=${media-title}") - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .spawn() -} - -#[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(SpawnFn(Box::new(mpv_spawn))), - favorites: load_favorites(), - show_favorites_only: false, - } - } -} - -impl App { - pub fn without_player(mut self) -> Self { - self.spawn_player = None; - self - } -} - -impl App { - 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 draw(&mut self, frame: &mut Frame) { - let area = frame.area(); - - let [main, footer] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); - - let [left, right] = - Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]) - .areas(main); - - let [top_left, bottom_left] = - Layout::vertical([Constraint::Length(3), Constraint::Fill(1)]).areas(left); - - let [top_right, bottom_right] = - Layout::vertical([Constraint::Length(5), Constraint::Fill(1)]).areas(right); - - // ── Info / now-playing panel ─────────────────────────────────────── - let info_content: Vec = { - let song_line = match &self.current_song { - Some(song) => Line::from(vec![ - Span::styled("▶ ", Style::default().fg(Color::Green)), - Span::styled( - song.clone(), - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ), - ]), - None => Line::from(Span::styled( - "No station playing", - Style::default().add_modifier(Modifier::DIM), - )), - }; - - let station = self - .current_station - .as_ref() - .or_else(|| self.station_list_state.selected().and_then(|i| self.filtered_stations.get(i))); - - if let Some(s) = station { - let name_line = Line::from(Span::styled( - s.name.clone(), - Style::default().add_modifier(Modifier::BOLD), - )); - let codec = if s.codec.is_empty() { "?".into() } else { s.codec.to_uppercase() }; - let country = if s.country_code.is_empty() { "--".into() } else { s.country_code.clone() }; - let meta_line = Line::from(Span::styled( - format!("{country} {codec} {}kbps", s.bitrate), - Style::default().add_modifier(Modifier::DIM), - )); - let error_line = if let Some(err) = &self.error_message { - Line::from(Span::styled(err.clone(), Style::default().fg(Color::Red))) - } else { - Line::from("") - }; - vec![song_line, name_line, meta_line, error_line] - } else { - let error_line = if let Some(err) = &self.error_message { - Line::from(Span::styled(err.clone(), Style::default().fg(Color::Red))) - } else { - Line::from("") - }; - vec![song_line, error_line] - } - }; - - let info_block = Block::bordered() - .title("Info") - .border_style(Style::default().fg(Color::Cyan)); - frame.render_widget(Paragraph::new(info_content).block(info_block), top_right); - - // ── Station list ─────────────────────────────────────────────────── - let available_width = bottom_left.width.saturating_sub(4) as usize; - let station_items: Vec = self - .filtered_stations - .iter() - .map(|s| { - let codec = if s.codec.is_empty() { "?".to_string() } else { s.codec.clone() }; - let country = if s.country_code.is_empty() { "--".to_string() } else { s.country_code.clone() }; - let meta = format!(" {country} {codec} {}k", s.bitrate); - let is_favorite = self.favorites.contains(&s.url); - let star_width = if is_favorite { 2 } else { 0 }; - let name_max = available_width.saturating_sub(meta.len() + star_width); - let name: String = if s.name.trim().len() > name_max { - format!( - "{}...", - s.name.trim().chars().take(name_max.saturating_sub(3)).collect::() - ) - } else { - s.name.trim().to_owned() - }; - - let is_playing = self - .current_station - .as_ref() - .map(|cs| cs.url == s.url) - .unwrap_or(false); - - let name_style = if is_playing { - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) - } else { - Style::default() - }; - - let mut spans = Vec::new(); - if is_favorite { - spans.push(Span::styled("★ ", Style::default().fg(Color::Yellow))); - } - spans.push(Span::styled(name, name_style)); - spans.push(Span::styled(meta, Style::default().add_modifier(Modifier::DIM))); - let name_line = Line::from(spans); - - let mut lines = vec![name_line]; - if !s.tags.is_empty() { - let tag_str: String = s.tags.chars().take(available_width).collect(); - lines.push(Line::from(Span::styled( - tag_str, - Style::default().fg(Color::DarkGray), - ))); - } - ListItem::new(ratatui::text::Text::from(lines)) - }) - .collect(); - - let list_title = if self.show_favorites_only { "Stations [★ Fav]" } else { "Stations [All]" }; - let stations_list = List::new(station_items) - .block(Block::bordered().title(list_title)) - .highlight_symbol(">>") - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - - frame.render_stateful_widget(stations_list, bottom_left, &mut self.station_list_state); - - // ── Search box ───────────────────────────────────────────────────── - let mut search_text = self.search_query.clone(); - let search_border_style = if self.input_mode == InputMode::Search { - search_text.push('█'); - Style::default().fg(Color::Yellow) - } else { - Style::default() - }; - frame.render_widget( - Paragraph::new(search_text).block( - Block::bordered() - .title("Search [/]") - .border_style(search_border_style), - ), - top_left, - ); - - // ── Played songs ─────────────────────────────────────────────────── - let song_items: Vec = self - .played_songs - .iter() - .map(|s| ListItem::new(s.clone())) - .collect(); - frame.render_widget( - List::new(song_items).block(Block::bordered().title("Played Songs")), - bottom_right, - ); - - // ── Footer key hints ─────────────────────────────────────────────── - let hints = if self.input_mode == InputMode::Search { - Line::from(vec![ - key_hint(" Esc "), Span::raw(" cancel "), - key_hint(" Enter "), Span::raw(" confirm"), - ]) - } else { - Line::from(vec![ - key_hint(" / "), Span::raw(" search "), - key_hint(" Esc "), Span::raw(" clear "), - key_hint(" j/k "), Span::raw(" navigate "), - key_hint(" ^U/^D "), Span::raw(" half page "), - key_hint(" Enter "), Span::raw(" play "), - key_hint(" s "), Span::raw(" stop "), - key_hint(" f "), Span::raw(" favorite "), - key_hint(" Tab "), Span::raw(" all/favs "), - key_hint(" q "), Span::raw(" quit"), - ]) - }; - frame.render_widget(Paragraph::new(hints), footer); - } - - async fn handle_events(&mut self) -> io::Result<()> { - if event::poll(std::time::Duration::from_millis(100))? { - match event::read()? { - Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { - self.handle_key_event(key_event).await; - } - _ => {} - } - } - 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 toggle_favorite(&mut self) { - if let Some(i) = self.station_list_state.selected() - && let Some(station) = self.filtered_stations.get(i) - { - let url = station.url.clone(); - if self.favorites.contains(&url) { - self.favorites.remove(&url); - } else { - self.favorites.insert(url); - } - save_favorites(&self.favorites); - if self.show_favorites_only { - self.update_filter(); - } - } - } - - pub async fn handle_key_event(&mut self, key_event: KeyEvent) { - match self.input_mode { - InputMode::Normal => match key_event.code { - KeyCode::Char('/') => { - self.input_mode = InputMode::Search; - self.search_query.clear(); - } - KeyCode::Char('q') => self.exit().await, - KeyCode::Tab => { - self.show_favorites_only = !self.show_favorites_only; - self.update_filter(); - } - KeyCode::Char('f') => self.toggle_favorite(), - KeyCode::Char('s') => self.stop_playback().await, - KeyCode::Esc if !self.search_query.is_empty() => { - self.search_query.clear(); - self.update_filter(); - } - KeyCode::Down | KeyCode::Char('j') => { - if let Some(i) = self.station_list_state.selected() - && !self.filtered_stations.is_empty() - { - let next = (i + 1) % self.filtered_stations.len(); - self.station_list_state.select(Some(next)); - } - } - KeyCode::Up | KeyCode::Char('k') => { - if let Some(i) = self.station_list_state.selected() - && !self.filtered_stations.is_empty() - { - let prev = if i == 0 { self.filtered_stations.len() - 1 } else { i - 1 }; - self.station_list_state.select(Some(prev)); - } - } - KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { - if let Some(i) = self.station_list_state.selected() - && !self.filtered_stations.is_empty() - { - let next = (i + 10).min(self.filtered_stations.len() - 1); - self.station_list_state.select(Some(next)); - } - } - KeyCode::Char('u') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { - if let Some(i) = self.station_list_state.selected() - && !self.filtered_stations.is_empty() - { - self.station_list_state.select(Some(i.saturating_sub(10))); - } - } - KeyCode::Enter => { - if let Some(i) = self.station_list_state.selected() - && let Some(station) = self.filtered_stations.get(i).cloned() - { - self.play_station(station).await; - } - } - _ => {} - }, - InputMode::Search => match key_event.code { - KeyCode::Char(c) => { - self.search_query.push(c); - self.update_filter(); - } - KeyCode::Backspace => { - self.search_query.pop(); - self.update_filter(); - } - KeyCode::Esc => { - self.search_query.clear(); - self.update_filter(); - self.input_mode = InputMode::Normal; - } - KeyCode::Enter => { - self.input_mode = InputMode::Normal; - } - _ => {} - }, - } - } - - async fn stop_playback(&mut self) { - if let Some(mut child) = self.player.take() { - let _ = child.kill().await; - } - self.current_station = None; - self.current_song = None; - self.song_rx = None; - self.error_message = None; - } - - async fn exit(&mut self) { - if let Some(mut child) = self.player.take() { - let _ = child.kill().await; - } - self.exit = true; - } - - 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(()) - } - - 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)); - } - } - - async fn play_station(&mut self, station: StationInfo) { - if let Some(mut child) = self.player.take() { - let _ = child.kill().await; - } - - self.error_message = None; - self.current_song = None; - self.current_station = Some(station.clone()); - - let Some(SpawnFn(ref spawn)) = self.spawn_player else { return }; - - let (tx, rx) = mpsc::unbounded_channel(); - self.song_rx = Some(rx); - - match spawn(&station.url) { - Err(e) => { - self.error_message = Some(format!("Failed to start mpv: {e}")); - self.current_station = None; - } - Ok(mut child) => { - let mut stdout = child.stdout.take().expect("mpv stdout"); - tokio::spawn(async move { - use tokio::io::{AsyncBufReadExt, BufReader}; - let mut reader = BufReader::new(&mut stdout).lines(); - while let Ok(Some(line)) = reader.next_line().await { - let title = line.trim().to_string(); - if !title.is_empty() { - let _ = tx.send(title); - } - } - }); - self.player = Some(child); - } - } - } -} - -fn key_hint(label: &str) -> Span<'_> { - Span::styled( - label, - Style::default() - .fg(Color::Black) - .bg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use crossterm::event::KeyModifiers; - - fn make_stations(names: &[&str]) -> Vec { - names - .iter() - .enumerate() - .map(|(i, n)| StationInfo { - name: n.to_string(), - url: format!("http://station{i}.test"), - country_code: "DE".to_string(), - bitrate: 128, - codec: "MP3".to_string(), - tags: String::new(), - }) - .collect() - } - - fn app_with(names: &[&str]) -> App { - let mut app = App::default().without_player(); - let stations = make_stations(names); - app.stations = stations.clone(); - app.filtered_stations = stations; - app - } - - fn key(code: KeyCode) -> KeyEvent { - KeyEvent::new(code, KeyModifiers::NONE) - } - - fn ctrl(code: KeyCode) -> KeyEvent { - KeyEvent::new(code, KeyModifiers::CONTROL) - } - - // --- navigation --- - - #[tokio::test] - async fn down_advances_selection() { - let mut app = app_with(&["A", "B", "C"]); - app.handle_key_event(key(KeyCode::Down)).await; - assert_eq!(app.station_list_state.selected(), Some(1)); - } - - #[tokio::test] - async fn j_advances_selection() { - let mut app = app_with(&["A", "B", "C"]); - app.handle_key_event(key(KeyCode::Char('j'))).await; - assert_eq!(app.station_list_state.selected(), Some(1)); - } - - #[tokio::test] - async fn down_wraps_at_bottom() { - let mut app = app_with(&["A", "B", "C"]); - app.station_list_state.select(Some(2)); - app.handle_key_event(key(KeyCode::Down)).await; - assert_eq!(app.station_list_state.selected(), Some(0)); - } - - #[tokio::test] - async fn up_wraps_at_top() { - let mut app = app_with(&["A", "B", "C"]); - app.handle_key_event(key(KeyCode::Up)).await; - assert_eq!(app.station_list_state.selected(), Some(2)); - } - - #[tokio::test] - async fn k_wraps_at_top() { - let mut app = app_with(&["A", "B", "C"]); - app.handle_key_event(key(KeyCode::Char('k'))).await; - assert_eq!(app.station_list_state.selected(), Some(2)); - } - - #[tokio::test] - async fn navigation_no_panic_on_empty_list() { - let mut app = App::default(); - app.handle_key_event(key(KeyCode::Char('j'))).await; - app.handle_key_event(key(KeyCode::Char('k'))).await; - assert_eq!(app.station_list_state.selected(), Some(0)); - } - - // --- half-page scroll --- - - #[tokio::test] - async fn ctrl_d_scrolls_down_half_page() { - let names: Vec<&str> = (0..25).map(|_| "X").collect(); - let mut app = app_with(&names); - app.station_list_state.select(Some(0)); - app.handle_key_event(ctrl(KeyCode::Char('d'))).await; - assert_eq!(app.station_list_state.selected(), Some(10)); - } - - #[tokio::test] - async fn ctrl_d_clamps_at_end() { - let names: Vec<&str> = (0..5).map(|_| "X").collect(); - let mut app = app_with(&names); - app.station_list_state.select(Some(3)); - app.handle_key_event(ctrl(KeyCode::Char('d'))).await; - assert_eq!(app.station_list_state.selected(), Some(4)); - } - - #[tokio::test] - async fn ctrl_u_scrolls_up_half_page() { - let names: Vec<&str> = (0..25).map(|_| "X").collect(); - let mut app = app_with(&names); - app.station_list_state.select(Some(20)); - app.handle_key_event(ctrl(KeyCode::Char('u'))).await; - assert_eq!(app.station_list_state.selected(), Some(10)); - } - - #[tokio::test] - async fn ctrl_u_clamps_at_start() { - let names: Vec<&str> = (0..5).map(|_| "X").collect(); - let mut app = app_with(&names); - app.station_list_state.select(Some(2)); - app.handle_key_event(ctrl(KeyCode::Char('u'))).await; - assert_eq!(app.station_list_state.selected(), Some(0)); - } - - #[tokio::test] - async fn esc_in_normal_mode_clears_search() { - let mut app = app_with(&["Rock FM", "Jazz Radio"]); - app.search_query = "rock".to_string(); - app.update_filter(); - assert_eq!(app.filtered_stations.len(), 1); - app.handle_key_event(key(KeyCode::Esc)).await; - assert!(app.search_query.is_empty()); - assert_eq!(app.filtered_stations.len(), 2); - } - - #[tokio::test] - async fn esc_in_normal_mode_no_op_when_no_search() { - let mut app = app_with(&["Rock FM"]); - app.handle_key_event(key(KeyCode::Esc)).await; - assert_eq!(app.input_mode, InputMode::Normal); - assert_eq!(app.filtered_stations.len(), 1); - } - - // --- mode switching --- - - #[tokio::test] - async fn slash_enters_search_mode() { - let mut app = App::default(); - app.handle_key_event(key(KeyCode::Char('/'))).await; - assert_eq!(app.input_mode, InputMode::Search); - assert!(app.search_query.is_empty()); - } - - #[tokio::test] - async fn esc_exits_search_and_clears_query() { - let mut app = app_with(&["Rock FM", "Jazz Radio"]); - app.input_mode = InputMode::Search; - // 'k' only appears in "Rock FM", not in "Jazz Radio" - app.handle_key_event(key(KeyCode::Char('k'))).await; - assert_eq!(app.filtered_stations.len(), 1); - app.handle_key_event(key(KeyCode::Esc)).await; - assert_eq!(app.input_mode, InputMode::Normal); - assert!(app.search_query.is_empty()); - assert_eq!(app.filtered_stations.len(), 2); // list restored - } - - #[tokio::test] - async fn enter_exits_search_keeps_filter() { - let mut app = app_with(&["Rock FM", "Jazz Radio"]); - app.input_mode = InputMode::Search; - // 'k' only appears in "Rock FM", not in "Jazz Radio" - app.handle_key_event(key(KeyCode::Char('k'))).await; - app.handle_key_event(key(KeyCode::Enter)).await; - assert_eq!(app.input_mode, InputMode::Normal); - assert_eq!(app.filtered_stations.len(), 1); // filter stays active - } - - #[tokio::test] - async fn q_sets_exit_flag() { - let mut app = App::default(); - app.handle_key_event(key(KeyCode::Char('q'))).await; - assert!(app.exit); - } - - // --- search filtering --- - - #[tokio::test] - async fn search_typing_filters_by_name() { - let mut app = app_with(&["Rock FM", "Jazz Radio", "Pop Station"]); - app.input_mode = InputMode::Search; - app.handle_key_event(key(KeyCode::Char('j'))).await; - assert_eq!(app.filtered_stations.len(), 1); - assert_eq!(app.filtered_stations[0].name, "Jazz Radio"); - } - - #[tokio::test] - async fn search_matches_tags() { - let mut app = app_with(&["Station A", "Station B"]); - app.stations[0].tags = "jazz,blues".to_string(); - app.stations[1].tags = "pop,rock".to_string(); - app.filtered_stations = app.stations.clone(); - app.input_mode = InputMode::Search; - for c in "jazz".chars() { - app.handle_key_event(key(KeyCode::Char(c))).await; - } - assert_eq!(app.filtered_stations.len(), 1); - assert_eq!(app.filtered_stations[0].name, "Station A"); - } - - #[tokio::test] - async fn search_is_case_insensitive() { - let mut app = app_with(&["Rock FM", "Jazz Radio"]); - app.input_mode = InputMode::Search; - for c in "ROCK".chars() { - app.handle_key_event(key(KeyCode::Char(c))).await; - } - assert_eq!(app.filtered_stations.len(), 1); - assert_eq!(app.filtered_stations[0].name, "Rock FM"); - } - - #[tokio::test] - async fn search_backspace_removes_char_and_updates_list() { - let mut app = app_with(&["Rock FM", "Jazz Radio"]); - app.input_mode = InputMode::Search; - app.handle_key_event(key(KeyCode::Char('j'))).await; - assert_eq!(app.filtered_stations.len(), 1); - app.handle_key_event(key(KeyCode::Backspace)).await; - assert!(app.search_query.is_empty()); - assert_eq!(app.filtered_stations.len(), 2); - } - - #[tokio::test] - async fn no_match_clears_selection() { - let mut app = app_with(&["Rock FM"]); - app.input_mode = InputMode::Search; - app.handle_key_event(key(KeyCode::Char('z'))).await; - assert!(app.filtered_stations.is_empty()); - assert_eq!(app.station_list_state.selected(), None); - } - - #[tokio::test] - async fn clearing_search_restores_full_list() { - let mut app = app_with(&["A", "B", "C"]); - app.search_query = "x".to_string(); - app.update_filter(); - assert!(app.filtered_stations.is_empty()); - app.search_query.clear(); - app.update_filter(); - assert_eq!(app.filtered_stations.len(), 3); - } - - // --- play_station (test stub) --- - - #[tokio::test] - async fn enter_plays_selected_station() { - let mut app = app_with(&["Rock FM", "Jazz Radio"]); - app.handle_key_event(key(KeyCode::Enter)).await; - assert_eq!(app.current_station.as_ref().map(|s| s.name.as_str()), Some("Rock FM")); - } - - #[tokio::test] - async fn enter_clears_error_on_new_play() { - let mut app = app_with(&["Rock FM"]); - app.error_message = Some("old error".to_string()); - app.handle_key_event(key(KeyCode::Enter)).await; - assert!(app.error_message.is_none()); - } - - // --- song tracking --- - - #[test] - fn first_song_sets_current_with_no_history() { - let mut app = App::default(); - app.update_current_song("Song A".to_string()); - assert_eq!(app.current_song, Some("Song A".to_string())); - assert!(app.played_songs.is_empty()); - } - - #[test] - fn new_song_moves_previous_to_front_of_history() { - let mut app = App::default(); - app.update_current_song("Song A".to_string()); - app.update_current_song("Song B".to_string()); - assert_eq!(app.current_song, Some("Song B".to_string())); - assert_eq!(app.played_songs, vec!["Song A"]); - } - - #[test] - fn same_song_not_added_to_history() { - let mut app = App::default(); - app.update_current_song("Song A".to_string()); - app.update_current_song("Song A".to_string()); - assert!(app.played_songs.is_empty()); - } - - #[test] - fn history_is_newest_first() { - let mut app = App::default(); - app.update_current_song("Song 1".to_string()); - app.update_current_song("Song 2".to_string()); - app.update_current_song("Song 3".to_string()); - assert_eq!(app.played_songs[0], "Song 2"); - assert_eq!(app.played_songs[1], "Song 1"); - } - - #[test] - fn played_songs_capped_at_20() { - let mut app = App::default(); - for i in 0..=21 { - app.update_current_song(format!("Song {i}")); - } - assert_eq!(app.played_songs.len(), 20); - } - - // --- channel-based song updates --- - - #[test] - fn check_song_updates_reads_from_channel() { - let mut app = App::default(); - let (tx, rx) = mpsc::unbounded_channel(); - app.song_rx = Some(rx); - tx.send("Live Song".to_string()).unwrap(); - app.check_song_updates(); - assert_eq!(app.current_song, Some("Live Song".to_string())); - } - - #[test] - fn check_song_updates_processes_multiple_messages() { - let mut app = App::default(); - let (tx, rx) = mpsc::unbounded_channel(); - app.song_rx = Some(rx); - tx.send("Song X".to_string()).unwrap(); - tx.send("Song Y".to_string()).unwrap(); - app.check_song_updates(); - assert_eq!(app.current_song, Some("Song Y".to_string())); - assert_eq!(app.played_songs[0], "Song X"); - } - - #[test] - fn check_song_updates_no_panic_without_channel() { - let mut app = App::default(); - app.check_song_updates(); - assert_eq!(app.current_song, None); - } -} diff --git a/src/radio/app/draw.rs b/src/radio/app/draw.rs new file mode 100644 index 0000000..96d48aa --- /dev/null +++ b/src/radio/app/draw.rs @@ -0,0 +1,186 @@ +use super::{App, InputMode}; +use crate::radio::station::StationInfo; +use ratatui::{ + Frame, + layout::{Constraint, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, List, ListItem, Paragraph}, +}; + +impl App { + pub(super) fn draw(&mut self, frame: &mut Frame) { + let area = frame.area(); + + let [main, footer] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + + let [left, right] = + Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]) + .areas(main); + + let [top_left, bottom_left] = + Layout::vertical([Constraint::Length(3), Constraint::Fill(1)]).areas(left); + + let [top_right, bottom_right] = + Layout::vertical([Constraint::Length(5), Constraint::Fill(1)]).areas(right); + + frame.render_widget(self.search_box(), top_left); + let station_list = self.station_list(bottom_left.width); + frame.render_stateful_widget(station_list, bottom_left, &mut self.station_list_state); + frame.render_widget(self.info_panel(), top_right); + frame.render_widget(self.played_songs_list(), bottom_right); + frame.render_widget(Paragraph::new(self.footer_hints()), footer); + } + + fn info_panel(&self) -> Paragraph<'static> { + let song_line = match &self.current_song { + Some(song) => Line::from(vec![ + Span::styled("▶ ", Style::default().fg(Color::Green)), + Span::styled( + song.clone(), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + ), + ]), + None => Line::from(Span::styled( + "No station playing", + Style::default().add_modifier(Modifier::DIM), + )), + }; + + let station = self + .current_station + .as_ref() + .or_else(|| self.station_list_state.selected().and_then(|i| self.filtered_stations.get(i))); + + let error_line = self + .error_message + .as_ref() + .map(|err| Line::from(Span::styled(err.clone(), Style::default().fg(Color::Red)))) + .unwrap_or_else(|| Line::from("")); + + let lines = if let Some(s) = station { + let codec = if s.codec.is_empty() { "?".into() } else { s.codec.to_uppercase() }; + let country = if s.country_code.is_empty() { "--".into() } else { s.country_code.clone() }; + vec![ + song_line, + Line::from(Span::styled(s.name.clone(), Style::default().add_modifier(Modifier::BOLD))), + Line::from(Span::styled( + format!("{country} {codec} {}kbps", s.bitrate), + Style::default().add_modifier(Modifier::DIM), + )), + error_line, + ] + } else { + vec![song_line, error_line] + }; + + Paragraph::new(lines).block( + Block::bordered().title("Info").border_style(Style::default().fg(Color::Cyan)), + ) + } + + fn station_item(&self, s: &StationInfo, available_width: usize) -> ListItem<'static> { + let codec = if s.codec.is_empty() { "?".to_string() } else { s.codec.clone() }; + let country = if s.country_code.is_empty() { "--".to_string() } else { s.country_code.clone() }; + let meta = format!(" {country} {codec} {}k", s.bitrate); + let is_favorite = self.favorites.contains(&s.url); + let star_width = if is_favorite { 2 } else { 0 }; + let name_max = available_width.saturating_sub(meta.len() + star_width); + let name: String = if s.name.trim().len() > name_max { + format!( + "{}...", + s.name.trim().chars().take(name_max.saturating_sub(3)).collect::() + ) + } else { + s.name.trim().to_owned() + }; + + let is_playing = self.current_station.as_ref().map(|cs| cs.url == s.url).unwrap_or(false); + let name_style = if is_playing { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let mut spans = Vec::new(); + if is_favorite { + spans.push(Span::styled("★ ", Style::default().fg(Color::Yellow))); + } + spans.push(Span::styled(name, name_style)); + spans.push(Span::styled(meta, Style::default().add_modifier(Modifier::DIM))); + + let mut lines = vec![Line::from(spans)]; + if !s.tags.is_empty() { + let tag_str: String = s.tags.chars().take(available_width).collect(); + lines.push(Line::from(Span::styled(tag_str, Style::default().fg(Color::DarkGray)))); + } + ListItem::new(ratatui::text::Text::from(lines)) + } + + fn station_list(&self, area_width: u16) -> List<'static> { + let available_width = area_width.saturating_sub(4) as usize; + let items: Vec = self + .filtered_stations + .iter() + .map(|s| self.station_item(s, available_width)) + .collect(); + let title = if self.show_favorites_only { "Stations [★ Fav]" } else { "Stations [All]" }; + List::new(items) + .block(Block::bordered().title(title)) + .highlight_symbol(">>") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + } + + fn search_box(&self) -> Paragraph<'static> { + let mut search_text = self.search_query.clone(); + let border_style = if self.input_mode == InputMode::Search { + search_text.push('█'); + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + Paragraph::new(search_text) + .block(Block::bordered().title("Search [/]").border_style(border_style)) + } + + fn played_songs_list(&self) -> List<'static> { + let items: Vec = self + .played_songs + .iter() + .map(|s| ListItem::new(s.clone())) + .collect(); + List::new(items).block(Block::bordered().title("Played Songs")) + } + + fn footer_hints(&self) -> Line<'static> { + if self.input_mode == InputMode::Search { + Line::from(vec![ + key_hint(" Esc "), Span::raw(" cancel "), + key_hint(" Enter "), Span::raw(" confirm"), + ]) + } else { + Line::from(vec![ + key_hint(" / "), Span::raw(" search "), + key_hint(" Esc "), Span::raw(" clear "), + key_hint(" j/k "), Span::raw(" navigate "), + key_hint(" ^U/^D "), Span::raw(" half page "), + key_hint(" Enter "), Span::raw(" play "), + key_hint(" s "), Span::raw(" stop "), + key_hint(" f "), Span::raw(" favorite "), + key_hint(" Tab "), Span::raw(" all/favs "), + key_hint(" q "), Span::raw(" quit"), + ]) + } + } +} + +fn key_hint(label: &'static str) -> Span<'static> { + Span::styled( + label, + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) +} diff --git a/src/radio/app/favorites.rs b/src/radio/app/favorites.rs new file mode 100644 index 0000000..b319b8a --- /dev/null +++ b/src/radio/app/favorites.rs @@ -0,0 +1,47 @@ +use super::App; +use std::collections::HashSet; +use std::path::PathBuf; + +pub(super) fn load_favorites() -> HashSet { + std::fs::read_to_string(favorites_path()) + .ok() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .map(|v| v.into_iter().collect()) + .unwrap_or_default() +} + +fn favorites_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".config").join("iradio").join("favorites.json") +} + +fn save_favorites(favorites: &HashSet) { + let path = favorites_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let mut list: Vec<&String> = favorites.iter().collect(); + list.sort(); + if let Ok(json) = serde_json::to_string(&list) { + let _ = std::fs::write(path, json); + } +} + +impl App { + pub(super) fn toggle_favorite(&mut self) { + if let Some(i) = self.station_list_state.selected() + && let Some(station) = self.filtered_stations.get(i) + { + let url = station.url.clone(); + if self.favorites.contains(&url) { + self.favorites.remove(&url); + } else { + self.favorites.insert(url); + } + save_favorites(&self.favorites); + if self.show_favorites_only { + self.update_filter(); + } + } + } +} diff --git a/src/radio/app/input.rs b/src/radio/app/input.rs new file mode 100644 index 0000000..609f94c --- /dev/null +++ b/src/radio/app/input.rs @@ -0,0 +1,99 @@ +use super::{App, InputMode}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use std::io; + +impl App { + pub(super) async fn handle_events(&mut self) -> io::Result<()> { + if event::poll(std::time::Duration::from_millis(100))? { + match event::read()? { + Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { + self.handle_key_event(key_event).await; + } + _ => {} + } + } + Ok(()) + } + + pub async fn handle_key_event(&mut self, key_event: KeyEvent) { + match self.input_mode { + InputMode::Normal => self.handle_normal_key(key_event).await, + InputMode::Search => self.handle_search_key(key_event), + } + } + + async fn handle_normal_key(&mut self, key_event: KeyEvent) { + match key_event.code { + KeyCode::Char('/') => { + self.input_mode = InputMode::Search; + self.search_query.clear(); + } + KeyCode::Char('q') => self.exit().await, + KeyCode::Tab => { + self.show_favorites_only = !self.show_favorites_only; + self.update_filter(); + } + KeyCode::Char('f') => self.toggle_favorite(), + KeyCode::Char('s') => self.stop_playback().await, + KeyCode::Esc if !self.search_query.is_empty() => { + self.search_query.clear(); + self.update_filter(); + } + KeyCode::Enter => { + if let Some(i) = self.station_list_state.selected() + && let Some(station) = self.filtered_stations.get(i).cloned() + { + self.play_station(station).await; + } + } + _ => self.handle_navigation(key_event), + } + } + + fn handle_navigation(&mut self, key_event: KeyEvent) { + let len = self.filtered_stations.len(); + if len == 0 { + return; + } + let Some(i) = self.station_list_state.selected() else { + return; + }; + match key_event.code { + KeyCode::Down | KeyCode::Char('j') => { + self.station_list_state.select(Some((i + 1) % len)); + } + KeyCode::Up | KeyCode::Char('k') => { + self.station_list_state.select(Some(if i == 0 { len - 1 } else { i - 1 })); + } + KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { + self.station_list_state.select(Some((i + 10).min(len - 1))); + } + KeyCode::Char('u') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { + self.station_list_state.select(Some(i.saturating_sub(10))); + } + _ => {} + } + } + + fn handle_search_key(&mut self, key_event: KeyEvent) { + match key_event.code { + KeyCode::Char(c) => { + self.search_query.push(c); + self.update_filter(); + } + KeyCode::Backspace => { + self.search_query.pop(); + self.update_filter(); + } + KeyCode::Esc => { + self.search_query.clear(); + self.update_filter(); + self.input_mode = InputMode::Normal; + } + KeyCode::Enter => { + self.input_mode = InputMode::Normal; + } + _ => {} + } + } +} diff --git a/src/radio/app/mod.rs b/src/radio/app/mod.rs new file mode 100644 index 0000000..32d041e --- /dev/null +++ b/src/radio/app/mod.rs @@ -0,0 +1,183 @@ +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(()) + } +} diff --git a/src/radio/app/player.rs b/src/radio/app/player.rs new file mode 100644 index 0000000..22a7fc8 --- /dev/null +++ b/src/radio/app/player.rs @@ -0,0 +1,86 @@ +use super::App; +use crate::radio::station::StationInfo; +use std::io; +use tokio::process::Child; +use tokio::sync::mpsc; + +#[allow(clippy::type_complexity)] +pub(super) struct SpawnFn(Box io::Result + Send + Sync>); + +impl std::fmt::Debug for SpawnFn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SpawnFn") + } +} + +pub(super) fn new_mpv_spawn() -> SpawnFn { + SpawnFn(Box::new(mpv_spawn)) +} + +fn mpv_spawn(url: &str) -> io::Result { + tokio::process::Command::new("mpv") + .arg(url) + .arg("--no-video") + .arg("--quiet") + .arg("--term-playing-msg=${media-title}") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() +} + +impl App { + pub(super) async fn play_station(&mut self, station: StationInfo) { + if let Some(mut child) = self.player.take() { + let _ = child.kill().await; + } + + self.error_message = None; + self.current_song = None; + self.current_station = Some(station.clone()); + + let Some(SpawnFn(ref spawn)) = self.spawn_player else { + return; + }; + + let (tx, rx) = mpsc::unbounded_channel(); + self.song_rx = Some(rx); + + match spawn(&station.url) { + Err(e) => { + self.error_message = Some(format!("Failed to start mpv: {e}")); + self.current_station = None; + } + Ok(mut child) => { + let mut stdout = child.stdout.take().expect("mpv stdout"); + tokio::spawn(async move { + use tokio::io::{AsyncBufReadExt, BufReader}; + let mut reader = BufReader::new(&mut stdout).lines(); + while let Ok(Some(line)) = reader.next_line().await { + let title = line.trim().to_string(); + if !title.is_empty() { + let _ = tx.send(title); + } + } + }); + self.player = Some(child); + } + } + } + + pub(super) async fn stop_playback(&mut self) { + if let Some(mut child) = self.player.take() { + let _ = child.kill().await; + } + self.current_station = None; + self.current_song = None; + self.song_rx = None; + self.error_message = None; + } + + pub(super) async fn exit(&mut self) { + if let Some(mut child) = self.player.take() { + let _ = child.kill().await; + } + self.exit = true; + } +} diff --git a/src/radio/app/tests.rs b/src/radio/app/tests.rs new file mode 100644 index 0000000..bc93797 --- /dev/null +++ b/src/radio/app/tests.rs @@ -0,0 +1,340 @@ +use super::*; +use crate::radio::station::StationInfo; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use tokio::sync::mpsc; + +fn make_stations(names: &[&str]) -> Vec { + names + .iter() + .enumerate() + .map(|(i, n)| StationInfo { + name: n.to_string(), + url: format!("http://station{i}.test"), + country_code: "DE".to_string(), + bitrate: 128, + codec: "MP3".to_string(), + tags: String::new(), + }) + .collect() +} + +fn app_with(names: &[&str]) -> App { + let mut app = App::default().without_player(); + let stations = make_stations(names); + app.stations = stations.clone(); + app.filtered_stations = stations; + app +} + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn ctrl(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::CONTROL) +} + +// --- navigation --- + +#[tokio::test] +async fn down_advances_selection() { + let mut app = app_with(&["A", "B", "C"]); + app.handle_key_event(key(KeyCode::Down)).await; + assert_eq!(app.station_list_state.selected(), Some(1)); +} + +#[tokio::test] +async fn j_advances_selection() { + let mut app = app_with(&["A", "B", "C"]); + app.handle_key_event(key(KeyCode::Char('j'))).await; + assert_eq!(app.station_list_state.selected(), Some(1)); +} + +#[tokio::test] +async fn down_wraps_at_bottom() { + let mut app = app_with(&["A", "B", "C"]); + app.station_list_state.select(Some(2)); + app.handle_key_event(key(KeyCode::Down)).await; + assert_eq!(app.station_list_state.selected(), Some(0)); +} + +#[tokio::test] +async fn up_wraps_at_top() { + let mut app = app_with(&["A", "B", "C"]); + app.handle_key_event(key(KeyCode::Up)).await; + assert_eq!(app.station_list_state.selected(), Some(2)); +} + +#[tokio::test] +async fn k_wraps_at_top() { + let mut app = app_with(&["A", "B", "C"]); + app.handle_key_event(key(KeyCode::Char('k'))).await; + assert_eq!(app.station_list_state.selected(), Some(2)); +} + +#[tokio::test] +async fn navigation_no_panic_on_empty_list() { + let mut app = App::default(); + app.handle_key_event(key(KeyCode::Char('j'))).await; + app.handle_key_event(key(KeyCode::Char('k'))).await; + assert_eq!(app.station_list_state.selected(), Some(0)); +} + +// --- half-page scroll --- + +#[tokio::test] +async fn ctrl_d_scrolls_down_half_page() { + let names: Vec<&str> = (0..25).map(|_| "X").collect(); + let mut app = app_with(&names); + app.station_list_state.select(Some(0)); + app.handle_key_event(ctrl(KeyCode::Char('d'))).await; + assert_eq!(app.station_list_state.selected(), Some(10)); +} + +#[tokio::test] +async fn ctrl_d_clamps_at_end() { + let names: Vec<&str> = (0..5).map(|_| "X").collect(); + let mut app = app_with(&names); + app.station_list_state.select(Some(3)); + app.handle_key_event(ctrl(KeyCode::Char('d'))).await; + assert_eq!(app.station_list_state.selected(), Some(4)); +} + +#[tokio::test] +async fn ctrl_u_scrolls_up_half_page() { + let names: Vec<&str> = (0..25).map(|_| "X").collect(); + let mut app = app_with(&names); + app.station_list_state.select(Some(20)); + app.handle_key_event(ctrl(KeyCode::Char('u'))).await; + assert_eq!(app.station_list_state.selected(), Some(10)); +} + +#[tokio::test] +async fn ctrl_u_clamps_at_start() { + let names: Vec<&str> = (0..5).map(|_| "X").collect(); + let mut app = app_with(&names); + app.station_list_state.select(Some(2)); + app.handle_key_event(ctrl(KeyCode::Char('u'))).await; + assert_eq!(app.station_list_state.selected(), Some(0)); +} + +#[tokio::test] +async fn esc_in_normal_mode_clears_search() { + let mut app = app_with(&["Rock FM", "Jazz Radio"]); + app.search_query = "rock".to_string(); + app.update_filter(); + assert_eq!(app.filtered_stations.len(), 1); + app.handle_key_event(key(KeyCode::Esc)).await; + assert!(app.search_query.is_empty()); + assert_eq!(app.filtered_stations.len(), 2); +} + +#[tokio::test] +async fn esc_in_normal_mode_no_op_when_no_search() { + let mut app = app_with(&["Rock FM"]); + app.handle_key_event(key(KeyCode::Esc)).await; + assert_eq!(app.input_mode, InputMode::Normal); + assert_eq!(app.filtered_stations.len(), 1); +} + +// --- mode switching --- + +#[tokio::test] +async fn slash_enters_search_mode() { + let mut app = App::default(); + app.handle_key_event(key(KeyCode::Char('/'))).await; + assert_eq!(app.input_mode, InputMode::Search); + assert!(app.search_query.is_empty()); +} + +#[tokio::test] +async fn esc_exits_search_and_clears_query() { + let mut app = app_with(&["Rock FM", "Jazz Radio"]); + app.input_mode = InputMode::Search; + // 'k' only appears in "Rock FM", not in "Jazz Radio" + app.handle_key_event(key(KeyCode::Char('k'))).await; + assert_eq!(app.filtered_stations.len(), 1); + app.handle_key_event(key(KeyCode::Esc)).await; + assert_eq!(app.input_mode, InputMode::Normal); + assert!(app.search_query.is_empty()); + assert_eq!(app.filtered_stations.len(), 2); +} + +#[tokio::test] +async fn enter_exits_search_keeps_filter() { + let mut app = app_with(&["Rock FM", "Jazz Radio"]); + app.input_mode = InputMode::Search; + // 'k' only appears in "Rock FM", not in "Jazz Radio" + app.handle_key_event(key(KeyCode::Char('k'))).await; + app.handle_key_event(key(KeyCode::Enter)).await; + assert_eq!(app.input_mode, InputMode::Normal); + assert_eq!(app.filtered_stations.len(), 1); +} + +#[tokio::test] +async fn q_sets_exit_flag() { + let mut app = App::default(); + app.handle_key_event(key(KeyCode::Char('q'))).await; + assert!(app.exit); +} + +// --- search filtering --- + +#[tokio::test] +async fn search_typing_filters_by_name() { + let mut app = app_with(&["Rock FM", "Jazz Radio", "Pop Station"]); + app.input_mode = InputMode::Search; + app.handle_key_event(key(KeyCode::Char('j'))).await; + assert_eq!(app.filtered_stations.len(), 1); + assert_eq!(app.filtered_stations[0].name, "Jazz Radio"); +} + +#[tokio::test] +async fn search_matches_tags() { + let mut app = app_with(&["Station A", "Station B"]); + app.stations[0].tags = "jazz,blues".to_string(); + app.stations[1].tags = "pop,rock".to_string(); + app.filtered_stations = app.stations.clone(); + app.input_mode = InputMode::Search; + for c in "jazz".chars() { + app.handle_key_event(key(KeyCode::Char(c))).await; + } + assert_eq!(app.filtered_stations.len(), 1); + assert_eq!(app.filtered_stations[0].name, "Station A"); +} + +#[tokio::test] +async fn search_is_case_insensitive() { + let mut app = app_with(&["Rock FM", "Jazz Radio"]); + app.input_mode = InputMode::Search; + for c in "ROCK".chars() { + app.handle_key_event(key(KeyCode::Char(c))).await; + } + assert_eq!(app.filtered_stations.len(), 1); + assert_eq!(app.filtered_stations[0].name, "Rock FM"); +} + +#[tokio::test] +async fn search_backspace_removes_char_and_updates_list() { + let mut app = app_with(&["Rock FM", "Jazz Radio"]); + app.input_mode = InputMode::Search; + app.handle_key_event(key(KeyCode::Char('j'))).await; + assert_eq!(app.filtered_stations.len(), 1); + app.handle_key_event(key(KeyCode::Backspace)).await; + assert!(app.search_query.is_empty()); + assert_eq!(app.filtered_stations.len(), 2); +} + +#[tokio::test] +async fn no_match_clears_selection() { + let mut app = app_with(&["Rock FM"]); + app.input_mode = InputMode::Search; + app.handle_key_event(key(KeyCode::Char('z'))).await; + assert!(app.filtered_stations.is_empty()); + assert_eq!(app.station_list_state.selected(), None); +} + +#[tokio::test] +async fn clearing_search_restores_full_list() { + let mut app = app_with(&["A", "B", "C"]); + app.search_query = "x".to_string(); + app.update_filter(); + assert!(app.filtered_stations.is_empty()); + app.search_query.clear(); + app.update_filter(); + assert_eq!(app.filtered_stations.len(), 3); +} + +// --- play_station (test stub) --- + +#[tokio::test] +async fn enter_plays_selected_station() { + let mut app = app_with(&["Rock FM", "Jazz Radio"]); + app.handle_key_event(key(KeyCode::Enter)).await; + assert_eq!(app.current_station.as_ref().map(|s| s.name.as_str()), Some("Rock FM")); +} + +#[tokio::test] +async fn enter_clears_error_on_new_play() { + let mut app = app_with(&["Rock FM"]); + app.error_message = Some("old error".to_string()); + app.handle_key_event(key(KeyCode::Enter)).await; + assert!(app.error_message.is_none()); +} + +// --- song tracking --- + +#[test] +fn first_song_sets_current_with_no_history() { + let mut app = App::default(); + app.update_current_song("Song A".to_string()); + assert_eq!(app.current_song, Some("Song A".to_string())); + assert!(app.played_songs.is_empty()); +} + +#[test] +fn new_song_moves_previous_to_front_of_history() { + let mut app = App::default(); + app.update_current_song("Song A".to_string()); + app.update_current_song("Song B".to_string()); + assert_eq!(app.current_song, Some("Song B".to_string())); + assert_eq!(app.played_songs, vec!["Song A"]); +} + +#[test] +fn same_song_not_added_to_history() { + let mut app = App::default(); + app.update_current_song("Song A".to_string()); + app.update_current_song("Song A".to_string()); + assert!(app.played_songs.is_empty()); +} + +#[test] +fn history_is_newest_first() { + let mut app = App::default(); + app.update_current_song("Song 1".to_string()); + app.update_current_song("Song 2".to_string()); + app.update_current_song("Song 3".to_string()); + assert_eq!(app.played_songs[0], "Song 2"); + assert_eq!(app.played_songs[1], "Song 1"); +} + +#[test] +fn played_songs_capped_at_20() { + let mut app = App::default(); + for i in 0..=21 { + app.update_current_song(format!("Song {i}")); + } + assert_eq!(app.played_songs.len(), 20); +} + +// --- channel-based song updates --- + +#[test] +fn check_song_updates_reads_from_channel() { + let mut app = App::default(); + let (tx, rx) = mpsc::unbounded_channel(); + app.song_rx = Some(rx); + tx.send("Live Song".to_string()).unwrap(); + app.check_song_updates(); + assert_eq!(app.current_song, Some("Live Song".to_string())); +} + +#[test] +fn check_song_updates_processes_multiple_messages() { + let mut app = App::default(); + let (tx, rx) = mpsc::unbounded_channel(); + app.song_rx = Some(rx); + tx.send("Song X".to_string()).unwrap(); + tx.send("Song Y".to_string()).unwrap(); + app.check_song_updates(); + assert_eq!(app.current_song, Some("Song Y".to_string())); + assert_eq!(app.played_songs[0], "Song X"); +} + +#[test] +fn check_song_updates_no_panic_without_channel() { + let mut app = App::default(); + app.check_song_updates(); + assert_eq!(app.current_song, None); +}