Added favorites feature

This commit is contained in:
2026-05-29 17:10:13 +02:00
parent 041f070aa6
commit b6b87919b6
4 changed files with 154 additions and 9 deletions
Generated
+3 -1
View File
@@ -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",
]
+3 -1
View File
@@ -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"] }
+64
View File
@@ -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]`).
+84 -7
View File
@@ -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<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>);
@@ -57,6 +84,8 @@ pub struct App {
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 {
@@ -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 {