initial commit

This commit is contained in:
2025-08-03 21:18:48 +02:00
commit 3e36f25582
9 changed files with 3514 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
pub mod radio;
+18
View File
@@ -0,0 +1,18 @@
use color_eyre::Result;
use iradio::radio::{app::App, tui};
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let mut terminal = tui::init()?;
let mut app = App::default();
app.load_stations().await?;
let app_result = app.run(&mut terminal);
if let Err(err) = tui::restore() {
eprintln!(
"failed to restore terminal. Run `reset` or restart your terminal to recover: {err}"
);
}
app_result
}
+154
View File
@@ -0,0 +1,154 @@
use color_eyre::{
Result,
eyre::{WrapErr, eyre},
};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use radiobrowser::RadioBrowserAPI;
use ratatui::{
DefaultTerminal, Frame,
layout::{Constraint, Layout},
widgets::{Block, Paragraph},
};
use std::io::{self};
use crate::radio::station::StationInfo;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{List, ListItem, ListState};
#[derive(Debug, Default)]
pub struct App {
exit: bool,
stations: Vec<StationInfo>,
selected_index: usize,
scroll_offset: usize,
}
impl App {
/// runs the application's main loop until the user quits
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
while !self.exit {
terminal.draw(|frame| self.draw(frame))?;
self.handle_events().wrap_err("handle events failed")?;
}
Ok(())
}
fn draw(&self, frame: &mut Frame) {
let area = frame.area();
let [left, right] =
Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
.areas(area);
let [top_right, bottom_right] =
Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(right);
// Render the left block (e.g., station selection)
let items: Vec<ListItem> = self
.stations
.iter()
.skip(self.scroll_offset)
.take(15) // max visible
.map(|s| ListItem::new(s.name.clone()))
.collect();
let mut list_state = ListState::default();
list_state.select(Some(self.selected_index - self.scroll_offset));
let list = List::new(items)
.block(Block::bordered().title("Stations"))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("");
frame.render_stateful_widget(list, left, &mut list_state);
// frame.render_widget(Block::bordered().title("Station Selection"), left);
// Render the top-right block (e.g., song history)
frame.render_widget(Block::bordered().title("Played Songs"), top_right);
let station = self.stations.get(self.selected_index);
let paragraph = Paragraph::new(
station
.map(|s| format!("Now selected: {}\n{}", s.name, s.url))
.unwrap_or_else(|| "No station selected".into()),
)
.block(Block::bordered().title("Info"));
frame.render_widget(paragraph, bottom_right);
// frame.render_widget(Block::bordered().title("Info / Controls"), bottom_right);
}
fn handle_events(&mut self) -> io::Result<()> {
match event::read()? {
// it's important to check that the event is a key press event as
// crossterm also emits key release and repeat events on Windows.
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
self.handle_key_event(key_event)
}
_ => {}
};
Ok(())
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Char('q') => self.exit(),
KeyCode::Down | KeyCode::Char('j') => {
if self.selected_index + 1 < self.stations.len() {
self.selected_index += 1;
if self.selected_index >= self.scroll_offset + 15 {
self.scroll_offset += 1;
}
}
}
KeyCode::Up | KeyCode::Char('k') => {
if self.selected_index > 0 {
self.selected_index -= 1;
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
}
}
_ => {}
}
}
fn exit(&mut self) {
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 stations = api
.get_stations()
.country("DE")
.limit("50")
.send()
.await
.map_err(|e| eyre!("Failed to fetch station: {e}"))?;
eprintln!("Loaded {} stations", stations.len());
if self.stations.is_empty() {
self.stations.push(StationInfo {
name: "No stations found".into(),
url: "".into(),
});
}
self.stations = stations
.into_iter()
.map(|s: radiobrowser::ApiStation| StationInfo {
name: s.name,
url: s.url,
})
.collect();
Ok(())
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod app;
pub mod station;
pub mod tui;
+5
View File
@@ -0,0 +1,5 @@
#[derive(Debug, Clone)]
pub struct StationInfo {
pub name: String,
pub url: String,
}
+36
View File
@@ -0,0 +1,36 @@
use std::io::{self, Stdout, stdout};
use ratatui::{
Terminal,
backend::CrosstermBackend,
crossterm::{
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
},
};
/// A type alias for the terminal type used in this application
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
/// Initialize the terminal
pub fn init() -> io::Result<Tui> {
execute!(stdout(), EnterAlternateScreen)?;
enable_raw_mode()?;
set_panic_hook();
Terminal::new(CrosstermBackend::new(stdout()))
}
fn set_panic_hook() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = restore(); // ignore any errors as we are already failing
hook(panic_info);
}));
}
/// Restore the terminal to its original state
pub fn restore() -> io::Result<()> {
execute!(stdout(), LeaveAlternateScreen)?;
disable_raw_mode()?;
Ok(())
}