initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
*.log
|
||||||
Generated
+3283
File diff suppressed because it is too large
Load Diff
+12
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "iradio"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
color-eyre = "0.6.5"
|
||||||
|
crossterm = "0.29.0"
|
||||||
|
io = "0.0.2"
|
||||||
|
radiobrowser = "0.6.1"
|
||||||
|
ratatui = "0.29.0"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
@@ -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