main
  1use anyhow::{Context, Result};
  2use crossterm::{
  3    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
  4    execute,
  5    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
  6};
  7use ratatui::{
  8    backend::{Backend, CrosstermBackend},
  9    layout::{Constraint, Direction, Layout, Rect},
 10    style::{Color, Modifier, Style},
 11    text::{Line, Span},
 12    widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
 13    Frame, Terminal,
 14};
 15use std::io;
 16
 17mod app;
 18mod config;
 19mod feed;
 20mod player;
 21mod podcast_ui;
 22
 23use app::{App, CurrentScreen};
 24
 25fn main() -> Result<()> {
 26    // Create app first
 27    let mut app = App::new()?;
 28
 29    // Check if we're in a proper terminal environment
 30    // Note: Temporarily disabled TTY check for debugging
 31    // if !atty::is(atty::Stream::Stdout) {
 32    //     return Err(anyhow::anyhow!("This application requires a terminal/TTY to run"));
 33    // }
 34
 35    // Try different approach for macOS compatibility
 36    let mut stdout = io::stdout();
 37
 38    // Set up panic handler to cleanup terminal
 39    std::panic::set_hook(Box::new(|_| {
 40        let _ = disable_raw_mode();
 41        let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
 42    }));
 43
 44    // Try to enable raw mode with a fallback
 45    let raw_mode_enabled = match enable_raw_mode() {
 46        Ok(()) => true,
 47        Err(e) => {
 48            eprintln!("Warning: Could not enable raw mode: {}", e);
 49            eprintln!("This is a known issue on some terminal environments.");
 50            eprintln!("Common solutions:");
 51            eprintln!("  1. Run in Terminal.app or iTerm2 directly");
 52            eprintln!("  2. Check terminal permissions in System Preferences");
 53            eprintln!("  3. Try running with: script -q /dev/null cargo run");
 54            eprintln!("");
 55            eprintln!("For testing purposes, we'll continue without raw mode...");
 56            false
 57        }
 58    };
 59
 60    if !raw_mode_enabled {
 61        // Simple mode - just show the config and exit
 62        println!("\n=== GHETTO-BLASTER CONFIG TEST ===");
 63        println!("App initialized successfully!");
 64        println!("Loaded {} feeds:", app.feeds.len());
 65        for (i, feed) in app.feeds.iter().enumerate() {
 66            println!("  {}. {}", i + 1, feed.name);
 67            println!("     Episodes loaded: {}", feed.episodes.len());
 68        }
 69        println!("\nTo use the full TUI interface, run this app in a proper terminal.");
 70        return Ok(());
 71    }
 72
 73    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
 74        .with_context(|| "Failed to setup terminal screen")?;
 75    let backend = CrosstermBackend::new(stdout);
 76    let mut terminal = Terminal::new(backend).with_context(|| "Failed to create terminal")?;
 77
 78    // Main loop
 79    let res = run_app(&mut terminal, &mut app);
 80
 81    // Cleanup terminal
 82    disable_raw_mode()?;
 83    execute!(
 84        terminal.backend_mut(),
 85        LeaveAlternateScreen,
 86        DisableMouseCapture
 87    )?;
 88    terminal.show_cursor()?;
 89
 90    if let Err(err) = res {
 91        println!("{err:?}");
 92    }
 93
 94    Ok(())
 95}
 96
 97fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
 98    loop {
 99        // Update feeds from background loading
100        app.update_feeds();
101
102        terminal.draw(|f| ui(f, app))?;
103
104        // Use poll instead of read to avoid blocking, allowing for regular UI updates
105        if event::poll(std::time::Duration::from_millis(100))? {
106            if let Event::Key(key) = event::read()? {
107                match app.current_screen {
108                    CurrentScreen::FeedList => match key.code {
109                        KeyCode::Char('q') => return Ok(()),
110                        KeyCode::Char('j') | KeyCode::Down => app.next_feed(),
111                        KeyCode::Char('k') | KeyCode::Up => app.previous_feed(),
112                        KeyCode::Enter => app.select_feed(),
113                        KeyCode::Char('r') => app.refresh_feeds()?,
114                        _ => {}
115                    },
116                    CurrentScreen::EpisodeList => match key.code {
117                        KeyCode::Char('q') => return Ok(()),
118                        KeyCode::Char('h') | KeyCode::Left | KeyCode::Esc => app.back_to_feeds(),
119                        KeyCode::Char('j') | KeyCode::Down => app.next_episode(),
120                        KeyCode::Char('k') | KeyCode::Up => app.previous_episode(),
121                        KeyCode::Enter | KeyCode::Char(' ') => app.play_episode()?,
122                        KeyCode::Char('m') => app.go_to_music_discovery(),
123                        _ => {}
124                    },
125                    CurrentScreen::NowPlaying => match key.code {
126                        KeyCode::Char('q') => return Ok(()),
127                        KeyCode::Char('h') | KeyCode::Left | KeyCode::Esc => app.back_to_episodes(),
128                        KeyCode::Char(' ') => app.toggle_playback()?,
129                        KeyCode::Char('s') => app.stop_playback(),
130                        KeyCode::Char('+') | KeyCode::Char('=') => app.volume_up()?,
131                        KeyCode::Char('-') => app.volume_down()?,
132                        // 15-second seeking
133                        KeyCode::Char('l') | KeyCode::Right => app.seek_forward()?,
134                        KeyCode::Char('j') | KeyCode::Down => app.seek_backward()?,
135                        // 1-minute seeking
136                        KeyCode::Char('L') => app.skip_forward_long()?,
137                        KeyCode::Char('J') => app.skip_backward_long()?,
138                        KeyCode::Char('m') => app.go_to_music_discovery(),
139                        _ => {}
140                    },
141                    CurrentScreen::MusicDiscovery => match key.code {
142                        KeyCode::Char('q') => return Ok(()),
143                        KeyCode::Char('h') | KeyCode::Left | KeyCode::Esc => app.back_to_episodes(),
144                        KeyCode::Char('j') | KeyCode::Down => app.next_genre(),
145                        KeyCode::Char('k') | KeyCode::Up => app.previous_genre(),
146                        _ => {}
147                    },
148                }
149            }
150        }
151    }
152}
153
154fn render_music_discovery(f: &mut Frame, app: &App, area: Rect) {
155    let chunks = Layout::default()
156        .direction(Direction::Vertical)
157        .constraints([
158            Constraint::Length(3),  // Title
159            Constraint::Length(4),  // Genre selector
160            Constraint::Min(8),     // Shows list
161            Constraint::Length(3),  // Help
162        ])
163        .split(area);
164
165    // Title
166    let title = Paragraph::new("🎵 CJSW Music Discovery")
167        .style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))
168        .block(Block::default().borders(Borders::ALL))
169        .alignment(ratatui::layout::Alignment::Center);
170    f.render_widget(title, chunks[0]);
171
172    // Genre selector
173    let genres = app.get_unique_genres();
174    let selected_genre = if genres.is_empty() {
175        "No genres available".to_string()
176    } else {
177        genres.get(app.selected_genre).unwrap_or(&"Unknown".to_string()).clone()
178    };
179    
180    let genre_info = format!("Genre: {} ({}/{})", selected_genre, app.selected_genre + 1, genres.len());
181    let genre_widget = Paragraph::new(genre_info)
182        .style(Style::default().fg(Color::Yellow))
183        .block(Block::default().title("Browse Genres").borders(Borders::ALL));
184    f.render_widget(genre_widget, chunks[1]);
185
186    // Shows list
187    if !genres.is_empty() {
188        let shows = app.get_shows_by_genre(&selected_genre);
189        let show_items: Vec<ListItem> = shows
190            .iter()
191            .map(|show| {
192                ListItem::new(Line::from(vec![
193                    Span::styled(&show.name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
194                    Span::raw(" - "),
195                    Span::styled(&show.time_slot, Style::default().fg(Color::Cyan)),
196                    Span::raw(" - "),
197                    Span::styled(&show.day, Style::default().fg(Color::Green)),
198                ]))
199            })
200            .collect();
201
202        let shows_list = List::new(show_items)
203            .block(Block::default().title(format!("{} Shows", selected_genre)).borders(Borders::ALL))
204            .style(Style::default().fg(Color::White));
205        f.render_widget(shows_list, chunks[2]);
206    } else {
207        let no_shows = Paragraph::new("No shows available")
208            .style(Style::default().fg(Color::DarkGray))
209            .block(Block::default().title("Shows").borders(Borders::ALL))
210            .alignment(ratatui::layout::Alignment::Center);
211        f.render_widget(no_shows, chunks[2]);
212    }
213
214    // Help
215    let help_text = "Navigation: j/k or ↑/↓ - browse genres | ESC/h - back | q - quit";
216    let help = Paragraph::new(help_text)
217        .style(Style::default().fg(Color::DarkGray))
218        .block(Block::default().title("Help").borders(Borders::ALL));
219    f.render_widget(help, chunks[3]);
220}
221
222fn ui(f: &mut Frame, app: &App) {
223    let chunks = Layout::default()
224        .direction(Direction::Horizontal)
225        .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
226        .split(f.size());
227
228    // Left panel - Feeds
229    let feeds: Vec<ListItem> = app
230        .feeds
231        .iter()
232        .map(|feed| {
233            let display_name = match &feed.state {
234                crate::feed::FeedState::Loading => format!("{}", feed.name),
235                crate::feed::FeedState::Loaded => {
236                    format!("{} ({})", feed.name, feed.episodes.len())
237                }
238                crate::feed::FeedState::Error(_) => format!("{}", feed.name),
239            };
240            ListItem::new(Line::from(display_name))
241        })
242        .collect();
243
244    let feeds_list = List::new(feeds)
245        .block(Block::default().title("Feeds").borders(Borders::ALL))
246        .style(Style::default().fg(Color::White))
247        .highlight_style(
248            Style::default()
249                .add_modifier(Modifier::BOLD)
250                .bg(Color::DarkGray),
251        )
252        .highlight_symbol("");
253
254    let mut feeds_state = ListState::default();
255    feeds_state.select(Some(app.selected_feed));
256    f.render_stateful_widget(feeds_list, chunks[0], &mut feeds_state);
257
258    // Right panel - Episodes or info
259    match app.current_screen {
260        CurrentScreen::FeedList => {
261            let loading_count = app
262                .feeds
263                .iter()
264                .filter(|f| matches!(f.state, crate::feed::FeedState::Loading))
265                .count();
266            let loaded_count = app
267                .feeds
268                .iter()
269                .filter(|f| matches!(f.state, crate::feed::FeedState::Loaded))
270                .count();
271            let error_count = app
272                .feeds
273                .iter()
274                .filter(|f| matches!(f.state, crate::feed::FeedState::Error(_)))
275                .count();
276
277            let info_text = if app.feeds.is_empty() {
278                "No feeds configured!
279
280Edit ~/.config/ghetto-blaster.yml
281to add podcast feeds.
282
283Then press 'r' to refresh."
284                    .to_string()
285            } else if loading_count > 0 {
286                format!(
287                    "Loading feeds... ({} loading, {} loaded, {} errors)
288
289⟳ = Loading
290✓ = Loaded (episode count)
291✗ = Error
292
293Navigation:
294• j/k or ↑/↓ - navigate
295• Enter - select feed
296• r - refresh feeds
297• q - quit",
298                    loading_count, loaded_count, error_count
299                )
300            } else {
301                "Navigation:
302• j/k or ↑/↓ - navigate
303• Enter - select feed
304• r - refresh feeds
305• q - quit"
306                    .to_string()
307            };
308
309            let info = Paragraph::new(info_text)
310                .block(Block::default().title("Help").borders(Borders::ALL));
311            f.render_widget(info, chunks[1]);
312        }
313        CurrentScreen::EpisodeList => {
314            if let Some(selected_feed) = app.feeds.get(app.selected_feed) {
315                let episodes: Vec<ListItem> = selected_feed
316                    .episodes
317                    .iter()
318                    .map(|ep| {
319                        let cache_indicator = if ep.is_cached { "💾 " } else { "🌐 " };
320                        let title_with_cache = format!("{}{}", cache_indicator, ep.title);
321
322                        ListItem::new(Line::from(vec![
323                            Span::raw(title_with_cache),
324                            Span::styled(
325                                format!(" ({})", ep.published_at.format("%Y-%m-%d")),
326                                Style::default().fg(Color::DarkGray),
327                            ),
328                        ]))
329                    })
330                    .collect();
331
332                let episodes_list = List::new(episodes)
333                    .block(
334                        Block::default()
335                            .title(format!("Episodes - {}", selected_feed.name))
336                            .borders(Borders::ALL),
337                    )
338                    .style(Style::default().fg(Color::White))
339                    .highlight_style(
340                        Style::default()
341                            .add_modifier(Modifier::BOLD)
342                            .bg(Color::DarkGray),
343                    )
344                    .highlight_symbol("");
345
346                let mut episodes_state = ListState::default();
347                episodes_state.select(Some(app.selected_episode));
348                f.render_stateful_widget(episodes_list, chunks[1], &mut episodes_state);
349            }
350        }
351        CurrentScreen::NowPlaying => {
352            podcast_ui::render_now_playing_enhanced(f, app, chunks[1]);
353        }
354        CurrentScreen::MusicDiscovery => {
355            render_music_discovery(f, app, chunks[1]);
356        }
357    }
358}