Commit ba94d8c
2025-06-25 04:23:03
Changed files (7)
src/app.rs
@@ -0,0 +1,94 @@
+use anyhow::Result;
+use crate::{config::Config, feed::Feed, player::Player};
+
+#[derive(Debug, Clone, Copy)]
+pub enum CurrentScreen {
+ FeedList,
+ EpisodeList,
+}
+
+pub struct App {
+ pub current_screen: CurrentScreen,
+ pub feeds: Vec<Feed>,
+ pub selected_feed: usize,
+ pub selected_episode: usize,
+ pub config: Config,
+ pub player: Player,
+}
+
+impl App {
+ pub fn new() -> Result<Self> {
+ let config = Config::load()?;
+ let feeds = vec![]; // Will load from config
+
+ Ok(Self {
+ current_screen: CurrentScreen::FeedList,
+ feeds,
+ selected_feed: 0,
+ selected_episode: 0,
+ config,
+ player: Player::new(),
+ })
+ }
+
+ pub fn next_feed(&mut self) {
+ if !self.feeds.is_empty() {
+ self.selected_feed = (self.selected_feed + 1) % self.feeds.len();
+ }
+ }
+
+ pub fn previous_feed(&mut self) {
+ if !self.feeds.is_empty() {
+ self.selected_feed = if self.selected_feed == 0 {
+ self.feeds.len() - 1
+ } else {
+ self.selected_feed - 1
+ };
+ }
+ }
+
+ pub fn next_episode(&mut self) {
+ if let Some(feed) = self.feeds.get(self.selected_feed) {
+ if !feed.episodes.is_empty() {
+ self.selected_episode = (self.selected_episode + 1) % feed.episodes.len();
+ }
+ }
+ }
+
+ pub fn previous_episode(&mut self) {
+ if let Some(feed) = self.feeds.get(self.selected_feed) {
+ if !feed.episodes.is_empty() {
+ self.selected_episode = if self.selected_episode == 0 {
+ feed.episodes.len() - 1
+ } else {
+ self.selected_episode - 1
+ };
+ }
+ }
+ }
+
+ pub fn select_feed(&mut self) {
+ if !self.feeds.is_empty() {
+ self.current_screen = CurrentScreen::EpisodeList;
+ self.selected_episode = 0;
+ }
+ }
+
+ pub fn back_to_feeds(&mut self) {
+ self.current_screen = CurrentScreen::FeedList;
+ }
+
+ pub fn refresh_feeds(&mut self) -> Result<()> {
+ // TODO: Implement feed refresh
+ Ok(())
+ }
+
+ pub fn play_episode(&mut self) -> Result<()> {
+ if let Some(feed) = self.feeds.get(self.selected_feed) {
+ if let Some(episode) = feed.episodes.get(self.selected_episode) {
+ self.player.play(&episode.enclosure_url)?;
+ }
+ }
+ Ok(())
+ }
+}
\ No newline at end of file
src/config.rs
@@ -0,0 +1,85 @@
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::fs;
+use std::path::PathBuf;
+
+#[derive(Debug, Deserialize, Serialize)]
+pub struct Config {
+ pub feeds: HashMap<String, String>,
+ pub radio: Option<HashMap<String, String>>,
+ pub music_dirs: Option<Vec<String>>,
+}
+
+impl Config {
+ pub fn load() -> Result<Self> {
+ let config_path = Self::config_path()?;
+
+ if !config_path.exists() {
+ // Create a default config
+ let default_config = Self::default();
+ default_config.save()?;
+ return Ok(default_config);
+ }
+
+ let content = fs::read_to_string(&config_path)
+ .with_context(|| format!("Failed to read config file: {:?}", config_path))?;
+
+ let config: Config = serde_yaml::from_str(&content)
+ .with_context(|| "Failed to parse config file")?;
+
+ Ok(config)
+ }
+
+ pub fn save(&self) -> Result<()> {
+ let config_path = Self::config_path()?;
+
+ if let Some(parent) = config_path.parent() {
+ fs::create_dir_all(parent)
+ .with_context(|| format!("Failed to create config directory: {:?}", parent))?;
+ }
+
+ let content = serde_yaml::to_string(self)
+ .with_context(|| "Failed to serialize config")?;
+
+ fs::write(&config_path, content)
+ .with_context(|| format!("Failed to write config file: {:?}", config_path))?;
+
+ Ok(())
+ }
+
+ fn config_path() -> Result<PathBuf> {
+ let config_dir = dirs::config_dir()
+ .with_context(|| "Failed to find config directory")?;
+
+ Ok(config_dir.join("ghetto-blaster.yml"))
+ }
+}
+
+impl Default for Config {
+ fn default() -> Self {
+ let mut feeds = HashMap::new();
+ feeds.insert(
+ "Rust Podcast".to_string(),
+ "https://feeds.feedburner.com/rustacean-station".to_string(),
+ );
+ feeds.insert(
+ "Tech Talk".to_string(),
+ "https://feeds.feedburner.com/thetechguys".to_string(),
+ );
+
+ let mut radio = HashMap::new();
+ radio.insert(
+ "CBC Radio 1".to_string(),
+ "https://cbc-radio1-moncton.cast.addradio.de/cbc/radio1/moncton/mp3/high".to_string(),
+ );
+
+ Self {
+ feeds,
+ radio: Some(radio),
+ music_dirs: Some(vec![
+ "~/Music".to_string(),
+ ]),
+ }
+ }
+}
\ No newline at end of file
src/feed.rs
@@ -0,0 +1,101 @@
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use quick_xml::de::from_str;
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone)]
+pub struct Feed {
+ pub name: String,
+ pub url: String,
+ pub episodes: Vec<Episode>,
+}
+
+#[derive(Debug, Clone)]
+pub struct Episode {
+ pub title: String,
+ pub description: String,
+ pub enclosure_url: String,
+ pub published_at: DateTime<Utc>,
+ pub duration: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+struct RssChannel {
+ #[serde(rename = "item")]
+ items: Vec<RssItem>,
+}
+
+#[derive(Debug, Deserialize)]
+struct RssItem {
+ title: Option<String>,
+ description: Option<String>,
+ enclosure: Option<RssEnclosure>,
+ #[serde(rename = "pubDate")]
+ pub_date: Option<String>,
+ #[serde(rename = "duration", default)]
+ duration: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+struct RssEnclosure {
+ #[serde(rename = "@url")]
+ url: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct Rss {
+ channel: RssChannel,
+}
+
+impl Feed {
+ pub fn new(name: String, url: String) -> Self {
+ Self {
+ name,
+ url,
+ episodes: Vec::new(),
+ }
+ }
+
+ pub fn fetch_episodes(&mut self) -> Result<()> {
+ let response = reqwest::blocking::get(&self.url)
+ .with_context(|| format!("Failed to fetch feed: {}", self.url))?;
+
+ let content = response.text()
+ .with_context(|| "Failed to read response body")?;
+
+ let rss: Rss = from_str(&content)
+ .with_context(|| "Failed to parse RSS feed")?;
+
+ self.episodes = rss.channel.items
+ .into_iter()
+ .filter_map(|item| self.parse_episode(item))
+ .collect();
+
+ // Sort episodes by date (newest first)
+ self.episodes.sort_by(|a, b| b.published_at.cmp(&a.published_at));
+
+ Ok(())
+ }
+
+ fn parse_episode(&self, item: RssItem) -> Option<Episode> {
+ let title = item.title?;
+ let enclosure_url = item.enclosure?.url;
+
+ let published_at = item.pub_date
+ .and_then(|date_str| {
+ // Try parsing RFC 2822 format first
+ DateTime::parse_from_rfc2822(&date_str)
+ .map(|dt| dt.with_timezone(&Utc))
+ .ok()
+ })
+ .unwrap_or_else(|| Utc::now());
+
+ Some(Episode {
+ title,
+ description: item.description.unwrap_or_default(),
+ enclosure_url,
+ published_at,
+ duration: item.duration,
+ })
+ }
+}
\ No newline at end of file
src/main.rs
@@ -0,0 +1,151 @@
+use anyhow::Result;
+use crossterm::{
+ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
+ execute,
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
+};
+use ratatui::{
+ backend::{Backend, CrosstermBackend},
+ layout::{Constraint, Direction, Layout},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
+ Frame, Terminal,
+};
+use std::io;
+
+mod app;
+mod config;
+mod feed;
+mod player;
+
+use app::{App, CurrentScreen};
+
+fn main() -> Result<()> {
+ // Setup terminal
+ enable_raw_mode()?;
+ let mut stdout = io::stdout();
+ execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
+ let backend = CrosstermBackend::new(stdout);
+ let mut terminal = Terminal::new(backend)?;
+
+ // Create app
+ let mut app = App::new()?;
+
+ // Main loop
+ let res = run_app(&mut terminal, &mut app);
+
+ // Cleanup terminal
+ disable_raw_mode()?;
+ execute!(
+ terminal.backend_mut(),
+ LeaveAlternateScreen,
+ DisableMouseCapture
+ )?;
+ terminal.show_cursor()?;
+
+ if let Err(err) = res {
+ println!("{err:?}");
+ }
+
+ Ok(())
+}
+
+fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
+ loop {
+ terminal.draw(|f| ui(f, app))?;
+
+ if let Event::Key(key) = event::read()? {
+ match app.current_screen {
+ CurrentScreen::FeedList => match key.code {
+ KeyCode::Char('q') => return Ok(()),
+ KeyCode::Char('j') | KeyCode::Down => app.next_feed(),
+ KeyCode::Char('k') | KeyCode::Up => app.previous_feed(),
+ KeyCode::Enter => app.select_feed(),
+ KeyCode::Char('r') => app.refresh_feeds()?,
+ _ => {}
+ },
+ CurrentScreen::EpisodeList => match key.code {
+ KeyCode::Char('q') => return Ok(()),
+ KeyCode::Char('h') | KeyCode::Left | KeyCode::Esc => app.back_to_feeds(),
+ KeyCode::Char('j') | KeyCode::Down => app.next_episode(),
+ KeyCode::Char('k') | KeyCode::Up => app.previous_episode(),
+ KeyCode::Enter | KeyCode::Char(' ') => app.play_episode()?,
+ _ => {}
+ },
+ }
+ }
+ }
+}
+
+fn ui(f: &mut Frame, app: &App) {
+ let chunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
+ .split(f.size());
+
+ // Left panel - Feeds
+ let feeds: Vec<ListItem> = app
+ .feeds
+ .iter()
+ .map(|feed| ListItem::new(Line::from(feed.name.clone())))
+ .collect();
+
+ let feeds_list = List::new(feeds)
+ .block(Block::default().title("Feeds").borders(Borders::ALL))
+ .style(Style::default().fg(Color::White))
+ .highlight_style(
+ Style::default()
+ .add_modifier(Modifier::BOLD)
+ .bg(Color::DarkGray),
+ )
+ .highlight_symbol("▶ ");
+
+ let mut feeds_state = ListState::default();
+ feeds_state.select(Some(app.selected_feed));
+ f.render_stateful_widget(feeds_list, chunks[0], &mut feeds_state);
+
+ // Right panel - Episodes or info
+ match app.current_screen {
+ CurrentScreen::FeedList => {
+ let info = Paragraph::new("Select a feed with Enter\nj/k to navigate\nq to quit\nr to refresh")
+ .block(Block::default().title("Info").borders(Borders::ALL));
+ f.render_widget(info, chunks[1]);
+ }
+ CurrentScreen::EpisodeList => {
+ if let Some(selected_feed) = app.feeds.get(app.selected_feed) {
+ let episodes: Vec<ListItem> = selected_feed
+ .episodes
+ .iter()
+ .map(|ep| {
+ ListItem::new(Line::from(vec![
+ Span::raw(&ep.title),
+ Span::styled(
+ format!(" ({})", ep.published_at.format("%Y-%m-%d")),
+ Style::default().fg(Color::DarkGray),
+ ),
+ ]))
+ })
+ .collect();
+
+ let episodes_list = List::new(episodes)
+ .block(
+ Block::default()
+ .title(format!("Episodes - {}", selected_feed.name))
+ .borders(Borders::ALL),
+ )
+ .style(Style::default().fg(Color::White))
+ .highlight_style(
+ Style::default()
+ .add_modifier(Modifier::BOLD)
+ .bg(Color::DarkGray),
+ )
+ .highlight_symbol("♪ ");
+
+ let mut episodes_state = ListState::default();
+ episodes_state.select(Some(app.selected_episode));
+ f.render_stateful_widget(episodes_list, chunks[1], &mut episodes_state);
+ }
+ }
+ }
+}
src/player.rs
@@ -0,0 +1,72 @@
+use anyhow::{Context, Result};
+use std::process::{Child, Command, Stdio};
+
+pub struct Player {
+ current_process: Option<Child>,
+}
+
+impl Player {
+ pub fn new() -> Self {
+ Self {
+ current_process: None,
+ }
+ }
+
+ pub fn play(&mut self, url: &str) -> Result<()> {
+ // Stop any currently playing audio
+ self.stop();
+
+ // Start mpv process
+ let child = Command::new("mpv")
+ .args(&[
+ "--no-video",
+ "--osc=yes",
+ "--osd-level=1",
+ url
+ ])
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .spawn()
+ .with_context(|| "Failed to start mpv. Make sure mpv is installed.")?;
+
+ self.current_process = Some(child);
+ Ok(())
+ }
+
+ pub fn stop(&mut self) {
+ if let Some(mut process) = self.current_process.take() {
+ let _ = process.kill();
+ let _ = process.wait();
+ }
+ }
+
+ pub fn is_playing(&mut self) -> bool {
+ if let Some(process) = &mut self.current_process {
+ match process.try_wait() {
+ Ok(Some(_)) => {
+ // Process has exited
+ self.current_process = None;
+ false
+ }
+ Ok(None) => {
+ // Process is still running
+ true
+ }
+ Err(_) => {
+ // Error checking process, assume it's dead
+ self.current_process = None;
+ false
+ }
+ }
+ } else {
+ false
+ }
+ }
+}
+
+impl Drop for Player {
+ fn drop(&mut self) {
+ self.stop();
+ }
+}
\ No newline at end of file
.gitignore
@@ -0,0 +1,15 @@
+# Rust
+/target/
+Cargo.lock
+*.pdb
+
+# IDE
+.vscode/
+.idea/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Config (optional - you might want to track this)
+# ghetto-blaster.yml
Cargo.toml
@@ -0,0 +1,30 @@
+[package]
+name = "ghetto-blaster"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+# TUI framework
+ratatui = "0.25"
+crossterm = "0.27"
+
+# Config and data
+serde = { version = "1.0", features = ["derive"] }
+serde_yaml = "0.9"
+dirs = "5.0"
+
+# HTTP and XML parsing
+reqwest = { version = "0.11", features = ["blocking"] }
+quick-xml = { version = "0.31", features = ["serialize"] }
+
+# Date/time handling
+chrono = { version = "0.4", features = ["serde"] }
+
+# Error handling
+anyhow = "1.0"
+
+# Audio playback
+std-process = "0.1"
+
+# Async runtime
+tokio = { version = "1.0", features = ["full"] }