diff --git a/Cargo.lock b/Cargo.lock index 05ab4e7..b6bdc5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1498,13 +1498,15 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iradio" -version = "0.1.0" +version = "0.9.0" dependencies = [ "color-eyre", "crossterm 0.29.0", "io", "radiobrowser", "ratatui", + "serde", + "serde_json", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index c548fea..8416e3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iradio" -version = "0.1.0" +version = "0.9.0" edition = "2024" [dependencies] @@ -9,4 +9,6 @@ crossterm = "0.29.0" io = "0.0.2" radiobrowser = "0.6.1" ratatui = "0.29.0" +serde = { version = "1", features = ["derive"] } +serde_json = "1" tokio = { version = "1", features = ["full"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..8481398 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# iradio + +A terminal internet radio player built with Rust. Streams stations from [radio-browser.info](https://www.radio-browser.info) and plays them via `mpv`. + +## Requirements + +- [Rust / Cargo](https://rustup.rs) +- [`mpv`](https://mpv.io) — must be installed and on `PATH` + +## Build & Run + +```bash +cargo build --release +cargo run +``` + +## Features + +- Browse stations from Germany, Austria, and the US +- Live search (filters by name and tags) +- Now-playing display with song history +- Favorite stations — persisted to `~/.config/iradio/favorites.json` + +## Keybindings + +### Normal mode + +| Key | Action | +|-----------|-------------------------------| +| `j` / `↓` | Move down | +| `k` / `↑` | Move up | +| `Ctrl-D` | Scroll down half page | +| `Ctrl-U` | Scroll up half page | +| `Enter` | Play selected station | +| `s` | Stop playback | +| `f` | Toggle favorite on selection | +| `Tab` | Toggle All / Favorites view | +| `/` | Enter search mode | +| `Esc` | Clear active search filter | +| `q` | Quit | + +### Search mode + +| Key | Action | +|---------|-------------------------------------| +| typing | Filter station list live | +| `Enter` | Confirm filter, return to Normal | +| `Esc` | Clear filter, return to Normal | + +## Layout + +``` +┌─────────────────────┬──────────────────────────────┐ +│ Search [/] │ Info (now playing) │ +├─────────────────────┼──────────────────────────────┤ +│ │ │ +│ Stations [All] │ Played Songs │ +│ ★ NDR 2 │ Song title... │ +│ SWR3 │ ... │ +│ ... │ │ +└─────────────────────┴──────────────────────────────┘ +``` + +Favorites are marked with `★` in the station list. Press `Tab` to show only favorited stations (`Stations [★ Fav]`). diff --git a/src/radio/app.rs b/src/radio/app.rs index c8755df..2d9e491 100644 --- a/src/radio/app.rs +++ b/src/radio/app.rs @@ -12,10 +12,37 @@ use ratatui::{ 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>); @@ -57,6 +84,8 @@ pub struct App { song_rx: Option>, error_message: Option, spawn_player: Option, + favorites: HashSet, + show_favorites_only: bool, } impl Default for App { @@ -79,6 +108,8 @@ impl Default for App { song_rx: None, error_message: None, spawn_player: Some(SpawnFn(Box::new(mpv_spawn))), + favorites: load_favorites(), + show_favorites_only: false, } } } @@ -182,7 +213,9 @@ impl App { 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 name_max = available_width.saturating_sub(meta.len()); + 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!( "{}...", @@ -204,10 +237,13 @@ impl App { Style::default() }; - let name_line = Line::from(vec![ - Span::styled(name, name_style), - Span::styled(meta, Style::default().add_modifier(Modifier::DIM)), - ]); + 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() { @@ -221,8 +257,9 @@ impl App { }) .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("Stations")) + .block(Block::bordered().title(list_title)) .highlight_symbol(">>") .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); @@ -265,10 +302,13 @@ impl App { } else { Line::from(vec![ key_hint(" / "), Span::raw(" search "), - key_hint(" Esc "), Span::raw(" clear 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"), ]) }; @@ -318,6 +358,23 @@ impl App { 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 { @@ -326,6 +383,12 @@ impl App { 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(); @@ -392,6 +455,16 @@ impl App { } } + 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; @@ -454,6 +527,10 @@ impl App { .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 {