main
1use crate::app::{App, CurrentTrack};
2use ratatui::{
3 layout::{Constraint, Direction, Layout, Rect},
4 style::{Color, Modifier, Style},
5 text::{Line, Span},
6 widgets::{Block, Borders, Gauge, Paragraph, Wrap},
7 Frame,
8};
9
10pub fn render_now_playing_enhanced(f: &mut Frame, app: &App, area: Rect) {
11 if let Some(track) = &app.current_track {
12 // Create a podcast-focused layout
13 let main_chunks = Layout::default()
14 .direction(Direction::Vertical)
15 .constraints([
16 Constraint::Length(3), // Title bar
17 Constraint::Length(4), // Episode info header
18 Constraint::Min(8), // Episode details/description
19 Constraint::Length(4), // Progress and controls
20 ])
21 .split(area);
22
23 // Title bar
24 let title_bar = Paragraph::new("♪ GHETTO-BLASTER ♪")
25 .style(
26 Style::default()
27 .fg(Color::Green)
28 .add_modifier(Modifier::BOLD),
29 )
30 .block(Block::default().borders(Borders::ALL))
31 .alignment(ratatui::layout::Alignment::Center);
32 f.render_widget(title_bar, main_chunks[0]);
33
34 // Episode info header
35 render_episode_header(f, app, track, main_chunks[1]);
36
37 // Episode details area
38 render_episode_details(f, app, track, main_chunks[2]);
39
40 // Progress and controls
41 render_progress_and_controls(f, app, main_chunks[3]);
42 }
43}
44
45fn render_episode_header(f: &mut Frame, app: &App, track: &CurrentTrack, area: Rect) {
46 let header_chunks = Layout::default()
47 .direction(Direction::Horizontal)
48 .constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
49 .split(area);
50
51 // Episode and podcast info
52 let episode_info = format!(
53 "Episode: {}\nPodcast: {}",
54 truncate_string(&track.title, 50),
55 track.podcast_name
56 );
57 let info_widget = Paragraph::new(episode_info)
58 .style(Style::default().fg(Color::White))
59 .block(Block::default().title("Now Playing").borders(Borders::ALL));
60 f.render_widget(info_widget, header_chunks[0]);
61
62 // Status and cache info
63 let status_info = get_episode_status_info(app);
64 let status_widget = Paragraph::new(status_info)
65 .style(Style::default().fg(Color::Cyan))
66 .block(Block::default().title("Status").borders(Borders::ALL));
67 f.render_widget(status_widget, header_chunks[1]);
68}
69
70fn render_episode_details(f: &mut Frame, app: &App, _track: &CurrentTrack, area: Rect) {
71 let detail_chunks = Layout::default()
72 .direction(Direction::Horizontal)
73 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
74 .split(area);
75
76 // Episode metadata
77 render_episode_metadata(f, app, detail_chunks[0]);
78
79 // Episode description/show notes
80 render_episode_description(f, app, detail_chunks[1]);
81}
82
83fn render_episode_metadata(f: &mut Frame, app: &App, area: Rect) {
84 let mut metadata_lines = vec![
85 Line::from(Span::styled(
86 "Episode Metadata",
87 Style::default()
88 .fg(Color::Yellow)
89 .add_modifier(Modifier::BOLD),
90 )),
91 Line::from(""),
92 ];
93
94 // Get current episode data
95 if let Some(feed) = app.feeds.get(app.selected_feed) {
96 if let Some(episode) = feed.episodes.get(app.selected_episode) {
97 // Published date
98 let pub_date = episode.published_at.format("%B %d, %Y at %I:%M %p UTC");
99 metadata_lines.push(Line::from(vec![
100 Span::styled("Published: ", Style::default().fg(Color::Green)),
101 Span::raw(pub_date.to_string()),
102 ]));
103
104 // Duration
105 if let Some(duration) = &episode._duration {
106 metadata_lines.push(Line::from(vec![
107 Span::styled("Duration: ", Style::default().fg(Color::Green)),
108 Span::raw(duration.clone()),
109 ]));
110 }
111
112 // Cache status
113 let cache_status = if episode.is_cached {
114 ("Cached Locally", Color::Green)
115 } else {
116 ("Streaming", Color::Yellow)
117 };
118 metadata_lines.push(Line::from(vec![
119 Span::styled("Source: ", Style::default().fg(Color::Green)),
120 Span::styled(cache_status.0, Style::default().fg(cache_status.1)),
121 ]));
122
123 // File info if cached
124 if episode.is_cached {
125 if let Some(local_path) = &episode.local_path {
126 let filename = std::path::Path::new(local_path)
127 .file_name()
128 .unwrap_or_default()
129 .to_string_lossy();
130 metadata_lines.push(Line::from(vec![
131 Span::styled("File: ", Style::default().fg(Color::Green)),
132 Span::raw(filename.to_string()),
133 ]));
134 }
135 }
136
137 metadata_lines.push(Line::from(""));
138
139 // Playback position
140 let position_text = app.player.get_position_formatted();
141 let duration_text = app.player.get_duration_formatted();
142 metadata_lines.push(Line::from(vec![
143 Span::styled("Position: ", Style::default().fg(Color::Blue)),
144 Span::raw(format!("{} / {}", position_text, duration_text)),
145 ]));
146 }
147 }
148
149 let metadata_widget = Paragraph::new(metadata_lines)
150 .block(Block::default().title("Details").borders(Borders::ALL))
151 .wrap(Wrap { trim: true });
152 f.render_widget(metadata_widget, area);
153}
154
155fn render_episode_description(f: &mut Frame, app: &App, area: Rect) {
156 let mut description_text = "No description available.".to_string();
157
158 // Get episode description
159 if let Some(feed) = app.feeds.get(app.selected_feed) {
160 if let Some(episode) = feed.episodes.get(app.selected_episode) {
161 description_text = episode.description.clone();
162
163 // Clean up HTML tags if present
164 description_text = clean_html_tags(&description_text);
165
166 // Limit length for display
167 if description_text.len() > 800 {
168 description_text.truncate(800);
169 description_text.push_str("...");
170 }
171 }
172 }
173
174 let description_widget = Paragraph::new(description_text)
175 .style(Style::default().fg(Color::White))
176 .block(
177 Block::default()
178 .title("Episode Description")
179 .borders(Borders::ALL),
180 )
181 .wrap(Wrap { trim: true });
182 f.render_widget(description_widget, area);
183}
184
185fn render_progress_and_controls(f: &mut Frame, app: &App, area: Rect) {
186 let progress_chunks = Layout::default()
187 .direction(Direction::Horizontal)
188 .constraints([
189 Constraint::Percentage(30),
190 Constraint::Percentage(40),
191 Constraint::Percentage(30),
192 ])
193 .split(area);
194
195 // Volume gauge
196 let volume_gauge = Gauge::default()
197 .block(Block::default().title("Volume").borders(Borders::ALL))
198 .gauge_style(Style::default().fg(Color::Cyan))
199 .percent(app.volume as u16)
200 .label(format!("{}%", app.volume));
201 f.render_widget(volume_gauge, progress_chunks[0]);
202
203 // Progress bar with visual indicator
204 let position = app.player.get_position();
205 let progress_text = format!(
206 "{} / {}",
207 app.player.get_position_formatted(),
208 app.player.get_duration_formatted()
209 );
210
211 // Create a simple text-based progress bar if we have duration
212 let progress_display = if let Some(duration) = app.player.get_duration() {
213 let progress_percentage = ((position / duration) * 100.0) as u16;
214 let progress_gauge = Gauge::default()
215 .block(Block::default().title("Progress").borders(Borders::ALL))
216 .gauge_style(Style::default().fg(Color::Yellow))
217 .percent(progress_percentage.min(100))
218 .label(progress_text);
219 f.render_widget(progress_gauge, progress_chunks[1]);
220 return;
221 } else {
222 Paragraph::new(progress_text)
223 .style(Style::default().fg(Color::Yellow))
224 .block(Block::default().title("Progress").borders(Borders::ALL))
225 .alignment(ratatui::layout::Alignment::Center)
226 };
227
228 f.render_widget(progress_display, progress_chunks[1]);
229
230 // Controls help with seeking info
231 let controls_text = if app.player.is_paused() {
232 "⏸ PAUSED | SPACE:play | s:stop | ±:vol | j/l:±15s | J/L:±1m | m:music | ESC:back | q:quit"
233 } else {
234 "▶ PLAYING | SPACE:pause | s:stop | ±:vol | j/l:±15s | J/L:±1m | m:music | ESC:back | q:quit"
235 };
236 let controls_widget = Paragraph::new(controls_text)
237 .style(Style::default().fg(Color::DarkGray))
238 .block(Block::default().title("Controls").borders(Borders::ALL))
239 .wrap(Wrap { trim: true });
240 f.render_widget(controls_widget, progress_chunks[2]);
241}
242
243fn get_episode_status_info(app: &App) -> String {
244 let playback_status = if app.player.is_paused() {
245 "⏸ Paused"
246 } else {
247 "▶ Playing"
248 };
249
250 let cache_status = if let Some(feed) = app.feeds.get(app.selected_feed) {
251 if let Some(episode) = feed.episodes.get(app.selected_episode) {
252 if episode.is_cached {
253 "💾 Local"
254 } else {
255 "🌐 Stream"
256 }
257 } else {
258 "❓ Unknown"
259 }
260 } else {
261 "❓ Unknown"
262 };
263
264 format!("{}\n{}", playback_status, cache_status)
265}
266
267fn truncate_string(s: &str, max_len: usize) -> String {
268 if s.len() <= max_len {
269 s.to_string()
270 } else {
271 format!("{}...", &s[..max_len.saturating_sub(3)])
272 }
273}
274
275fn clean_html_tags(html: &str) -> String {
276 // Simple HTML tag removal (basic implementation)
277 let mut result = String::new();
278 let mut in_tag = false;
279
280 for ch in html.chars() {
281 match ch {
282 '<' => in_tag = true,
283 '>' => in_tag = false,
284 _ if !in_tag => result.push(ch),
285 _ => {}
286 }
287 }
288
289 // Clean up extra whitespace
290 result.split_whitespace().collect::<Vec<&str>>().join(" ")
291}