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}