Refactoring, improve code structure

This commit is contained in:
2026-05-29 17:46:47 +02:00
parent b6b87919b6
commit 38b5e0c557
9 changed files with 943 additions and 930 deletions
Generated
+1 -1
View File
@@ -1498,7 +1498,7 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]] [[package]]
name = "iradio" name = "iradio"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"color-eyre", "color-eyre",
"crossterm 0.29.0", "crossterm 0.29.0",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "iradio" name = "iradio"
version = "0.9.0" version = "0.9.1"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
-928
View File
@@ -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<String> {
std::fs::read_to_string(favorites_path())
.ok()
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.map(|v| v.into_iter().collect())
.unwrap_or_default()
}
fn save_favorites(favorites: &HashSet<String>) {
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<dyn Fn(&str) -> io::Result<Child> + 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<Child> {
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<StationInfo>,
station_list_state: ListState,
filtered_stations: Vec<StationInfo>,
search_query: String,
input_mode: InputMode,
player: Option<Child>,
current_station: Option<StationInfo>,
current_song: Option<String>,
played_songs: Vec<String>,
song_rx: Option<mpsc::UnboundedReceiver<String>>,
error_message: Option<String>,
spawn_player: Option<SpawnFn>,
favorites: HashSet<String>,
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<Line> = {
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<ListItem> = 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::<String>()
)
} 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<ListItem> = 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<StationInfo> = 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<StationInfo> {
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);
}
}
+186
View File
@@ -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::<String>()
)
} 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<ListItem> = 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<ListItem> = 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),
)
}
+47
View File
@@ -0,0 +1,47 @@
use super::App;
use std::collections::HashSet;
use std::path::PathBuf;
pub(super) fn load_favorites() -> HashSet<String> {
std::fs::read_to_string(favorites_path())
.ok()
.and_then(|s| serde_json::from_str::<Vec<String>>(&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<String>) {
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();
}
}
}
}
+99
View File
@@ -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;
}
_ => {}
}
}
}
+183
View File
@@ -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<StationInfo>,
station_list_state: ListState,
filtered_stations: Vec<StationInfo>,
search_query: String,
input_mode: InputMode,
player: Option<Child>,
current_station: Option<StationInfo>,
current_song: Option<String>,
played_songs: Vec<String>,
song_rx: Option<mpsc::UnboundedReceiver<String>>,
error_message: Option<String>,
spawn_player: Option<player::SpawnFn>,
favorites: HashSet<String>,
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<StationInfo> = 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(())
}
}
+86
View File
@@ -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<dyn Fn(&str) -> io::Result<Child> + 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<Child> {
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;
}
}
+340
View File
@@ -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<StationInfo> {
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);
}