Commit c442980

mo khan <mo@mokhan.ca>
2025-07-03 00:32:02
feat: implement async background feed loading with real-time UI updates
- TUI now starts instantly without blocking on RSS feed fetching - Feeds load in background threads with mpsc channels for communication - Real-time loading indicators show feed status: ⟳ = Loading, āœ“ = Loaded (episode count), āœ— = Error - Feed states track loading/loaded/error status - UI updates automatically as feeds finish loading - No more blocking startup - immediate responsiveness This matches modern app UX expectations with instant startup and background loading with visual feedback. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ac98c30
Changed files (3)
src/app.rs
@@ -1,5 +1,7 @@
 use anyhow::Result;
 use crate::{config::Config, feed::Feed, player::Player};
+use std::sync::mpsc;
+use std::thread;
 
 #[derive(Debug, Clone, Copy)]
 pub enum CurrentScreen {
@@ -17,6 +19,7 @@ pub struct App {
     pub player: Player,
     pub current_track: Option<CurrentTrack>,
     pub volume: u8,
+    feed_receiver: mpsc::Receiver<(usize, Feed)>,
 }
 
 #[derive(Debug, Clone)]
@@ -30,16 +33,25 @@ pub struct CurrentTrack {
 impl App {
     pub fn new() -> Result<Self> {
         let config = Config::load()?;
-        let mut feeds: Vec<Feed> = config.feeds
+        let feeds: Vec<Feed> = config.feeds
             .iter()
             .map(|(name, url)| Feed::new(name.clone(), url.clone()))
             .collect();
         
-        // Auto-load episodes on startup
-        for feed in &mut feeds {
-            if let Err(e) = feed.fetch_episodes() {
-                eprintln!("Failed to fetch episodes for {}: {}", feed.name, e);
-            }
+        // Set up async feed loading
+        let (sender, receiver) = mpsc::channel();
+        
+        // Start background feed loading
+        for (index, (name, url)) in config.feeds.iter().enumerate() {
+            let sender = sender.clone();
+            let name = name.clone();
+            let url = url.clone();
+            
+            thread::spawn(move || {
+                let mut feed = Feed::new(name, url);
+                let _ = feed.fetch_episodes(); // Ignore errors for now, state tracks them
+                let _ = sender.send((index, feed));
+            });
         }
         
         Ok(Self {
@@ -51,6 +63,7 @@ impl App {
             player: Player::new()?,
             current_track: None,
             volume: 70,  // Default volume 70%
+            feed_receiver: receiver,
         })
     }
 
@@ -173,4 +186,14 @@ impl App {
     pub fn back_to_episodes(&mut self) {
         self.current_screen = CurrentScreen::EpisodeList;
     }
+
+    // Check for and process incoming feed updates
+    pub fn update_feeds(&mut self) {
+        // Process all available feed updates without blocking
+        while let Ok((index, feed)) = self.feed_receiver.try_recv() {
+            if index < self.feeds.len() {
+                self.feeds[index] = feed;
+            }
+        }
+    }
 }
\ No newline at end of file
src/feed.rs
@@ -3,11 +3,19 @@ use chrono::{DateTime, Utc};
 use quick_xml::de::from_str;
 use serde::Deserialize;
 
+#[derive(Debug, Clone)]
+pub enum FeedState {
+    Loading,
+    Loaded,
+    Error(String),
+}
+
 #[derive(Debug, Clone)]
 pub struct Feed {
     pub name: String,
     pub url: String,
     pub episodes: Vec<Episode>,
+    pub state: FeedState,
 }
 
 #[derive(Debug, Clone)]
@@ -53,10 +61,26 @@ impl Feed {
             name,
             url,
             episodes: Vec::new(),
+            state: FeedState::Loading,
         }
     }
 
     pub fn fetch_episodes(&mut self) -> Result<()> {
+        self.state = FeedState::Loading;
+        
+        match self.try_fetch_episodes() {
+            Ok(()) => {
+                self.state = FeedState::Loaded;
+                Ok(())
+            }
+            Err(e) => {
+                self.state = FeedState::Error(e.to_string());
+                Err(e)
+            }
+        }
+    }
+
+    fn try_fetch_episodes(&mut self) -> Result<()> {
         let response = reqwest::blocking::get(&self.url)
             .with_context(|| format!("Failed to fetch feed: {}", self.url))?;
 
src/main.rs
@@ -96,6 +96,9 @@ fn main() -> Result<()> {
 
 fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
     loop {
+        // Update feeds from background loading
+        app.update_feeds();
+        
         terminal.draw(|f| ui(f, app))?;
 
         if let Event::Key(key) = event::read()? {
@@ -142,7 +145,14 @@ fn ui(f: &mut Frame, app: &App) {
     let feeds: Vec<ListItem> = app
         .feeds
         .iter()
-        .map(|feed| ListItem::new(Line::from(feed.name.clone())))
+        .map(|feed| {
+            let display_name = match &feed.state {
+                crate::feed::FeedState::Loading => format!("⟳ {}", feed.name),
+                crate::feed::FeedState::Loaded => format!("āœ“ {} ({})", feed.name, feed.episodes.len()),
+                crate::feed::FeedState::Error(_) => format!("āœ— {}", feed.name),
+            };
+            ListItem::new(Line::from(display_name))
+        })
         .collect();
 
     let feeds_list = List::new(feeds)
@@ -162,12 +172,35 @@ fn ui(f: &mut Frame, app: &App) {
     // Right panel - Episodes or info
     match app.current_screen {
         CurrentScreen::FeedList => {
+            let loading_count = app.feeds.iter().filter(|f| matches!(f.state, crate::feed::FeedState::Loading)).count();
+            let loaded_count = app.feeds.iter().filter(|f| matches!(f.state, crate::feed::FeedState::Loaded)).count();
+            let error_count = app.feeds.iter().filter(|f| matches!(f.state, crate::feed::FeedState::Error(_))).count();
+            
             let info_text = if app.feeds.is_empty() {
-                "No feeds configured!\n\nEdit ~/.config/ghetto-blaster.yml\nto add podcast feeds.\n\nThen press 'r' to refresh."
-            } else if app.feeds.iter().all(|f| f.episodes.is_empty()) {
-                "Loading episodes...\n\nIf this persists:\n• Check your internet connection\n• Verify feed URLs are valid\n• Press 'r' to refresh"
+                "No feeds configured!
+
+Edit ~/.config/ghetto-blaster.yml
+to add podcast feeds.
+
+Then press 'r' to refresh.".to_string()
+            } else if loading_count > 0 {
+                format!("Loading feeds... ({} loading, {} loaded, {} errors)
+
+⟳ = Loading
+āœ“ = Loaded (episode count)
+āœ— = Error
+
+Navigation:
+• j/k or ↑/↓ - navigate
+• Enter - select feed
+• r - refresh feeds
+• q - quit", loading_count, loaded_count, error_count)
             } else {
-                "Navigation:\n• j/k or ↑/↓ - navigate\n• Enter - select feed\n• r - refresh feeds\n• q - quit"
+                "Navigation:
+• j/k or ↑/↓ - navigate
+• Enter - select feed
+• r - refresh feeds
+• q - quit".to_string()
             };
             
             let info = Paragraph::new(info_text)