Added favorites feature
This commit is contained in:
Generated
+3
-1
@@ -1498,13 +1498,15 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iradio"
|
name = "iradio"
|
||||||
version = "0.1.0"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
"crossterm 0.29.0",
|
"crossterm 0.29.0",
|
||||||
"io",
|
"io",
|
||||||
"radiobrowser",
|
"radiobrowser",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "iradio"
|
name = "iradio"
|
||||||
version = "0.1.0"
|
version = "0.9.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -9,4 +9,6 @@ crossterm = "0.29.0"
|
|||||||
io = "0.0.2"
|
io = "0.0.2"
|
||||||
radiobrowser = "0.6.1"
|
radiobrowser = "0.6.1"
|
||||||
ratatui = "0.29.0"
|
ratatui = "0.29.0"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|||||||
@@ -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
@@ -12,10 +12,37 @@ use ratatui::{
|
|||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, List, ListItem, ListState, Paragraph},
|
widgets::{Block, List, ListItem, ListState, Paragraph},
|
||||||
};
|
};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tokio::process::Child;
|
use tokio::process::Child;
|
||||||
use tokio::sync::mpsc;
|
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)]
|
#[allow(clippy::type_complexity)]
|
||||||
struct SpawnFn(Box<dyn Fn(&str) -> io::Result<Child> + Send + Sync>);
|
struct SpawnFn(Box<dyn Fn(&str) -> io::Result<Child> + Send + Sync>);
|
||||||
|
|
||||||
@@ -57,6 +84,8 @@ pub struct App {
|
|||||||
song_rx: Option<mpsc::UnboundedReceiver<String>>,
|
song_rx: Option<mpsc::UnboundedReceiver<String>>,
|
||||||
error_message: Option<String>,
|
error_message: Option<String>,
|
||||||
spawn_player: Option<SpawnFn>,
|
spawn_player: Option<SpawnFn>,
|
||||||
|
favorites: HashSet<String>,
|
||||||
|
show_favorites_only: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for App {
|
impl Default for App {
|
||||||
@@ -79,6 +108,8 @@ impl Default for App {
|
|||||||
song_rx: None,
|
song_rx: None,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
spawn_player: Some(SpawnFn(Box::new(mpv_spawn))),
|
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 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 country = if s.country_code.is_empty() { "--".to_string() } else { s.country_code.clone() };
|
||||||
let meta = format!(" {country} {codec} {}k", s.bitrate);
|
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 {
|
let name: String = if s.name.trim().len() > name_max {
|
||||||
format!(
|
format!(
|
||||||
"{}...",
|
"{}...",
|
||||||
@@ -204,10 +237,13 @@ impl App {
|
|||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let name_line = Line::from(vec![
|
let mut spans = Vec::new();
|
||||||
Span::styled(name, name_style),
|
if is_favorite {
|
||||||
Span::styled(meta, Style::default().add_modifier(Modifier::DIM)),
|
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];
|
let mut lines = vec![name_line];
|
||||||
if !s.tags.is_empty() {
|
if !s.tags.is_empty() {
|
||||||
@@ -221,8 +257,9 @@ impl App {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let list_title = if self.show_favorites_only { "Stations [★ Fav]" } else { "Stations [All]" };
|
||||||
let stations_list = List::new(station_items)
|
let stations_list = List::new(station_items)
|
||||||
.block(Block::bordered().title("Stations"))
|
.block(Block::bordered().title(list_title))
|
||||||
.highlight_symbol(">>")
|
.highlight_symbol(">>")
|
||||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||||
|
|
||||||
@@ -265,10 +302,13 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
key_hint(" / "), Span::raw(" search "),
|
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(" j/k "), Span::raw(" navigate "),
|
||||||
key_hint(" ^U/^D "), Span::raw(" half page "),
|
key_hint(" ^U/^D "), Span::raw(" half page "),
|
||||||
key_hint(" Enter "), Span::raw(" play "),
|
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"),
|
key_hint(" q "), Span::raw(" quit"),
|
||||||
])
|
])
|
||||||
};
|
};
|
||||||
@@ -318,6 +358,23 @@ impl App {
|
|||||||
self.current_song = Some(song);
|
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) {
|
pub async fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||||
match self.input_mode {
|
match self.input_mode {
|
||||||
InputMode::Normal => match key_event.code {
|
InputMode::Normal => match key_event.code {
|
||||||
@@ -326,6 +383,12 @@ impl App {
|
|||||||
self.search_query.clear();
|
self.search_query.clear();
|
||||||
}
|
}
|
||||||
KeyCode::Char('q') => self.exit().await,
|
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() => {
|
KeyCode::Esc if !self.search_query.is_empty() => {
|
||||||
self.search_query.clear();
|
self.search_query.clear();
|
||||||
self.update_filter();
|
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) {
|
async fn exit(&mut self) {
|
||||||
if let Some(mut child) = self.player.take() {
|
if let Some(mut child) = self.player.take() {
|
||||||
let _ = child.kill().await;
|
let _ = child.kill().await;
|
||||||
@@ -454,6 +527,10 @@ impl App {
|
|||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.show_favorites_only {
|
||||||
|
self.filtered_stations.retain(|s| self.favorites.contains(&s.url));
|
||||||
|
}
|
||||||
|
|
||||||
if self.filtered_stations.is_empty() {
|
if self.filtered_stations.is_empty() {
|
||||||
self.station_list_state.select(None);
|
self.station_list_state.select(None);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user