initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
pub mod radio;
|
||||
+18
@@ -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
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod app;
|
||||
pub mod station;
|
||||
pub mod tui;
|
||||
@@ -0,0 +1,5 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StationInfo {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user