Commit ba94d8c

mo khan <mo@mokhan.ca>
2025-06-25 04:23:03
Initial TUI podcast player structure
- Set up ratatui-based TUI with vim-style keybindings - Created modular architecture: app, config, feed, player - Added mpv integration for audio playback - Implemented basic navigation and feed/episode listing - Added YAML config with default feeds 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
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"] }