184 lines
5.5 KiB
Rust
184 lines
5.5 KiB
Rust
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(())
|
|
}
|
|
}
|