Refactoring, improve code structure
This commit is contained in:
Generated
+1
-1
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "iradio"
|
name = "iradio"
|
||||||
version = "0.9.0"
|
version = "0.9.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user