Commit 7bcae0a
Changed files (5)
src/app.rs
@@ -1,9 +1,13 @@
use anyhow::Result;
-use crate::{config::Config, feed::Feed, player::Player};
+use crate::{
+ config::Config,
+ feed::Feed,
+ player::Player,
+};
use std::sync::mpsc;
use std::thread;
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CurrentScreen {
FeedList,
EpisodeList,
@@ -177,11 +181,19 @@ impl App {
}
pub fn seek_forward(&mut self) -> Result<()> {
- self.player.seek(10.0) // 10 seconds forward
+ self.player.skip_forward(15.0) // 15 seconds forward (common podcast increment)
}
pub fn seek_backward(&mut self) -> Result<()> {
- self.player.seek(-10.0) // 10 seconds backward
+ self.player.skip_backward(15.0) // 15 seconds backward
+ }
+
+ pub fn skip_forward_long(&mut self) -> Result<()> {
+ self.player.skip_forward(60.0) // 1 minute forward
+ }
+
+ pub fn skip_backward_long(&mut self) -> Result<()> {
+ self.player.skip_backward(60.0) // 1 minute backward
}
pub fn stop_playback(&mut self) {
@@ -203,4 +215,5 @@ impl App {
}
}
}
+
}
\ No newline at end of file
src/feed.rs
@@ -23,7 +23,7 @@ pub struct Feed {
#[derive(Debug, Clone)]
pub struct Episode {
pub title: String,
- pub _description: String,
+ pub description: String,
pub enclosure_url: String,
pub published_at: DateTime<Utc>,
pub _duration: Option<String>,
@@ -202,7 +202,7 @@ impl Feed {
Some(Episode {
title,
- _description: "Cached episode (no RSS data)".to_string(),
+ description: "Cached episode (no RSS data)".to_string(),
enclosure_url: format!("file://{}", path.display()), // Local file URL
published_at,
_duration: None,
@@ -226,7 +226,7 @@ impl Feed {
Some(Episode {
title,
- _description: item.description.unwrap_or_default(),
+ description: item.description.unwrap_or_default(),
enclosure_url,
published_at,
_duration: item.duration,
src/main.rs
@@ -18,6 +18,7 @@ mod app;
mod config;
mod feed;
mod player;
+mod podcast_ui;
use app::{App, CurrentScreen};
@@ -126,8 +127,12 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()>
KeyCode::Char('s') => app.stop_playback(),
KeyCode::Char('+') | KeyCode::Char('=') => app.volume_up()?,
KeyCode::Char('-') => app.volume_down()?,
+ // 15-second seeking
KeyCode::Char('l') | KeyCode::Right => app.seek_forward()?,
KeyCode::Char('j') | KeyCode::Down => app.seek_backward()?,
+ // 1-minute seeking
+ KeyCode::Char('L') => app.skip_forward_long()?,
+ KeyCode::Char('J') => app.skip_backward_long()?,
_ => {}
},
}
@@ -246,74 +251,8 @@ Navigation:
}
}
CurrentScreen::NowPlaying => {
- render_now_playing(f, app, chunks[1]);
+ podcast_ui::render_now_playing_enhanced(f, app, chunks[1]);
}
}
}
-fn render_now_playing(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
- if let Some(track) = &app.current_track {
- // Create a Winamp-inspired layout with multiple sections
- let main_chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3), // Title bar
- Constraint::Length(8), // Track info
- Constraint::Length(3), // Progress bar
- Constraint::Length(6), // Controls
- Constraint::Min(1), // Status/help
- ])
- .split(area);
-
- // Title bar (Winamp-style)
- let title_bar = Paragraph::new("♪ GHETTO-BLASTER ♪")
- .style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))
- .block(Block::default().borders(Borders::ALL))
- .alignment(ratatui::layout::Alignment::Center);
- f.render_widget(title_bar, main_chunks[0]);
-
- // Track info section
- let track_info = format!(
- "Now Playing:
-
-{}
-
-Podcast: {}",
- track.title,
- track.podcast_name
- );
- let info_widget = Paragraph::new(track_info)
- .style(Style::default().fg(Color::White))
- .block(Block::default().title("Track Info").borders(Borders::ALL))
- .wrap(ratatui::widgets::Wrap { trim: true });
- f.render_widget(info_widget, main_chunks[1]);
-
- // Progress bar (Unix-style with real-time position)
- let current_position = app.player.get_position();
- let progress_text = format!("Position: {:.1}s", current_position);
- let progress_bar = Paragraph::new(progress_text)
- .style(Style::default().fg(Color::Yellow))
- .block(Block::default().title("Progress").borders(Borders::ALL));
- f.render_widget(progress_bar, main_chunks[2]);
-
- // Volume gauge (Winamp-inspired)
- let volume_gauge = Gauge::default()
- .block(Block::default().title("Volume").borders(Borders::ALL))
- .gauge_style(Style::default().fg(Color::Cyan))
- .percent(app.volume as u16)
- .label(format!("{}%", app.volume));
- f.render_widget(volume_gauge, main_chunks[3]);
-
- // Controls/Help (tmux-style status line)
- let controls_text = if app.player.is_paused() {
- "⏸ PAUSED | SPACE:play | s:stop | ±:vol | h/l:seek | ESC:back | q:quit"
- } else {
- "▶ PLAYING | SPACE:pause | s:stop | ±:vol | h/l:seek | ESC:back | q:quit"
- };
- let controls = Paragraph::new(controls_text)
- .style(Style::default().fg(Color::DarkGray))
- .block(Block::default().title("Controls").borders(Borders::ALL))
- .wrap(ratatui::widgets::Wrap { trim: true });
- f.render_widget(controls, main_chunks[4]);
- }
-}
src/player.rs
@@ -1,5 +1,5 @@
use anyhow::{Context, Result};
-use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink};
+use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink, Source};
use std::fs;
use std::io::Cursor;
use std::path::PathBuf;
@@ -11,8 +11,14 @@ pub struct Player {
stream_handle: OutputStreamHandle,
sink: Option<Arc<Mutex<Sink>>>,
start_time: Option<Instant>,
+ pause_time: Option<Instant>,
+ paused_duration: std::time::Duration,
+ seek_position: f64, // Seconds
is_paused: bool,
volume: f32,
+ current_source: Option<Vec<u8>>, // Store audio data for seeking
+ current_cache_info: Option<(String, String)>, // For re-loading
+ current_url: Option<String>,
}
impl Player {
@@ -25,11 +31,18 @@ impl Player {
stream_handle,
sink: None,
start_time: None,
+ pause_time: None,
+ paused_duration: std::time::Duration::ZERO,
+ seek_position: 0.0,
is_paused: false,
volume: 0.7, // 70% volume
+ current_source: None,
+ current_cache_info: None,
+ current_url: None,
})
}
+
pub fn _play(&mut self, url: &str) -> Result<()> {
self.play_with_cache(url, None)
}
@@ -63,13 +76,41 @@ impl Player {
self.download_audio(source)?
};
+ // Store audio data and metadata for seeking
+ self.current_source = Some(audio_data.clone());
+ self.current_cache_info = cache_info.map(|(f, e)| (f.to_string(), e.to_string()));
+ self.current_url = Some(source.to_string());
+ self.seek_position = 0.0;
+ self.paused_duration = std::time::Duration::ZERO;
+
+ // Start playback from the beginning
+ self.start_playback_from_position(&audio_data, 0.0)?;
+
+ Ok(())
+ }
+
+ fn start_playback_from_position(&mut self, audio_data: &[u8], position_seconds: f64) -> Result<()> {
// Create a cursor from the audio data
- let cursor = Cursor::new(audio_data);
+ let cursor = Cursor::new(audio_data.to_vec());
// Decode the audio
- let source = Decoder::new(cursor)
+ let mut decoded_source = Decoder::new(cursor)
.with_context(|| "Failed to decode audio file. Unsupported format?")?;
+ // Skip to the desired position (approximate)
+ if position_seconds > 0.0 {
+ let sample_rate = decoded_source.sample_rate() as f64;
+ let channels = decoded_source.channels() as f64;
+ let samples_to_skip = (position_seconds * sample_rate * channels) as usize;
+
+ // Skip samples (this is approximate but works for seeking)
+ for _ in 0..samples_to_skip {
+ if decoded_source.next().is_none() {
+ break;
+ }
+ }
+ }
+
// Create a new sink
let sink = Sink::try_new(&self.stream_handle)
.with_context(|| "Failed to create audio sink")?;
@@ -78,12 +119,14 @@ impl Player {
sink.set_volume(self.volume);
// Add the source to the sink
- sink.append(source);
+ sink.append(decoded_source);
- // Store the sink and start time
+ // Store the sink and adjust timing
self.sink = Some(Arc::new(Mutex::new(sink)));
self.start_time = Some(Instant::now());
+ self.seek_position = position_seconds;
self.is_paused = false;
+ self.pause_time = None;
Ok(())
}
@@ -150,18 +193,29 @@ impl Player {
}
self.sink = None;
self.start_time = None;
+ self.pause_time = None;
+ self.paused_duration = std::time::Duration::ZERO;
+ self.seek_position = 0.0;
self.is_paused = false;
+ // Keep current_source, current_cache_info, current_url for potential resume
}
pub fn toggle_pause(&mut self) -> Result<()> {
if let Some(sink) = &self.sink {
if let Ok(sink) = sink.lock() {
if self.is_paused {
+ // Resume: adjust start time to account for pause duration
+ if let Some(pause_time) = self.pause_time {
+ self.paused_duration += pause_time.elapsed();
+ }
sink.play();
self.is_paused = false;
+ self.pause_time = None;
} else {
+ // Pause: record when we paused
sink.pause();
self.is_paused = true;
+ self.pause_time = Some(Instant::now());
}
}
}
@@ -178,28 +232,93 @@ impl Player {
Ok(())
}
- pub fn seek(&self, _seconds: f64) -> Result<()> {
- // Note: rodio doesn't support seeking easily
- // This is a limitation, but for now we'll just return Ok
- // In the future, we could implement this with symphonia directly
+ pub fn seek(&mut self, seconds: f64) -> Result<()> {
+ // Calculate new position
+ let current_pos = self.get_position();
+ let new_position = (current_pos + seconds).max(0.0);
+
+ // Seek to absolute position
+ self.seek_to(new_position)
+ }
+
+ pub fn seek_to(&mut self, position_seconds: f64) -> Result<()> {
+ // Can only seek if we have audio data loaded
+ if let Some(audio_data) = &self.current_source {
+ let audio_data = audio_data.clone();
+ let was_paused = self.is_paused;
+
+ // Stop current playback
+ if let Some(sink) = &self.sink {
+ if let Ok(sink) = sink.lock() {
+ sink.stop();
+ }
+ }
+
+ // Start playback from new position
+ self.start_playback_from_position(&audio_data, position_seconds)?;
+
+ // If we were paused, pause again
+ if was_paused {
+ self.toggle_pause()?;
+ }
+ }
+
Ok(())
}
+ // Jump forward by a specific amount (e.g., 15 seconds)
+ pub fn skip_forward(&mut self, seconds: f64) -> Result<()> {
+ self.seek(seconds)
+ }
+
+ // Jump backward by a specific amount (e.g., 15 seconds)
+ pub fn skip_backward(&mut self, seconds: f64) -> Result<()> {
+ self.seek(-seconds)
+ }
+
pub fn is_paused(&self) -> bool {
self.is_paused
}
pub fn get_position(&self) -> f64 {
if let Some(start_time) = self.start_time {
- if self.is_paused {
- // Return the last known position when paused
- // This is approximate since we don't track pause duration
- start_time.elapsed().as_secs_f64()
+ let elapsed = if self.is_paused {
+ // If paused, calculate time up to when we paused
+ if let Some(pause_time) = self.pause_time {
+ start_time.elapsed() - pause_time.elapsed()
+ } else {
+ start_time.elapsed()
+ }
} else {
- start_time.elapsed().as_secs_f64()
- }
+ // If playing, subtract total paused duration
+ start_time.elapsed() - self.paused_duration
+ };
+
+ // Add the seek position offset
+ self.seek_position + elapsed.as_secs_f64()
} else {
- 0.0
+ self.seek_position
+ }
+ }
+
+ // Get duration if available (this would require parsing the audio file)
+ pub fn get_duration(&self) -> Option<f64> {
+ // For now, return None - we could implement this by pre-parsing the audio file
+ // or storing duration from RSS feed metadata
+ None
+ }
+
+ // Get position as formatted time string
+ pub fn get_position_formatted(&self) -> String {
+ format_duration(self.get_position())
+ }
+
+ // Get duration as formatted time string
+ pub fn get_duration_formatted(&self) -> String {
+ if let Some(duration) = self.get_duration() {
+ format_duration(duration)
+ } else {
+ "--:--".to_string()
}
}
@@ -220,4 +339,18 @@ impl Drop for Player {
fn drop(&mut self) {
self.stop();
}
-}
\ No newline at end of file
+}
+
+fn format_duration(seconds: f64) -> String {
+ let total_seconds = seconds as u64;
+ let hours = total_seconds / 3600;
+ let minutes = (total_seconds % 3600) / 60;
+ let secs = total_seconds % 60;
+
+ if hours > 0 {
+ format!("{}:{:02}:{:02}", hours, minutes, secs)
+ } else {
+ format!("{}:{:02}", minutes, secs)
+ }
+}
+
src/podcast_ui.rs
@@ -0,0 +1,277 @@
+use crate::app::{App, CurrentTrack};
+use ratatui::{
+ layout::{Constraint, Direction, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Gauge, Paragraph, Wrap},
+ Frame,
+};
+
+pub fn render_now_playing_enhanced(f: &mut Frame, app: &App, area: Rect) {
+ if let Some(track) = &app.current_track {
+ // Create a podcast-focused layout
+ let main_chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3), // Title bar
+ Constraint::Length(4), // Episode info header
+ Constraint::Min(8), // Episode details/description
+ Constraint::Length(4), // Progress and controls
+ ])
+ .split(area);
+
+ // Title bar
+ let title_bar = Paragraph::new("♪ GHETTO-BLASTER ♪")
+ .style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))
+ .block(Block::default().borders(Borders::ALL))
+ .alignment(ratatui::layout::Alignment::Center);
+ f.render_widget(title_bar, main_chunks[0]);
+
+ // Episode info header
+ render_episode_header(f, app, track, main_chunks[1]);
+
+ // Episode details area
+ render_episode_details(f, app, track, main_chunks[2]);
+
+ // Progress and controls
+ render_progress_and_controls(f, app, main_chunks[3]);
+ }
+}
+
+fn render_episode_header(f: &mut Frame, app: &App, track: &CurrentTrack, area: Rect) {
+ let header_chunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
+ .split(area);
+
+ // Episode and podcast info
+ let episode_info = format!(
+ "Episode: {}\nPodcast: {}",
+ truncate_string(&track.title, 50),
+ track.podcast_name
+ );
+ let info_widget = Paragraph::new(episode_info)
+ .style(Style::default().fg(Color::White))
+ .block(Block::default().title("Now Playing").borders(Borders::ALL));
+ f.render_widget(info_widget, header_chunks[0]);
+
+ // Status and cache info
+ let status_info = get_episode_status_info(app);
+ let status_widget = Paragraph::new(status_info)
+ .style(Style::default().fg(Color::Cyan))
+ .block(Block::default().title("Status").borders(Borders::ALL));
+ f.render_widget(status_widget, header_chunks[1]);
+}
+
+fn render_episode_details(f: &mut Frame, app: &App, _track: &CurrentTrack, area: Rect) {
+ let detail_chunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
+ .split(area);
+
+ // Episode metadata
+ render_episode_metadata(f, app, detail_chunks[0]);
+
+ // Episode description/show notes
+ render_episode_description(f, app, detail_chunks[1]);
+}
+
+fn render_episode_metadata(f: &mut Frame, app: &App, area: Rect) {
+ let mut metadata_lines = vec![
+ Line::from(Span::styled("Episode Metadata", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))),
+ Line::from(""),
+ ];
+
+ // Get current episode data
+ if let Some(feed) = app.feeds.get(app.selected_feed) {
+ if let Some(episode) = feed.episodes.get(app.selected_episode) {
+ // Published date
+ let pub_date = episode.published_at.format("%B %d, %Y at %I:%M %p UTC");
+ metadata_lines.push(Line::from(vec![
+ Span::styled("Published: ", Style::default().fg(Color::Green)),
+ Span::raw(pub_date.to_string()),
+ ]));
+
+ // Duration
+ if let Some(duration) = &episode._duration {
+ metadata_lines.push(Line::from(vec![
+ Span::styled("Duration: ", Style::default().fg(Color::Green)),
+ Span::raw(duration.clone()),
+ ]));
+ }
+
+ // Cache status
+ let cache_status = if episode.is_cached {
+ ("Cached Locally", Color::Green)
+ } else {
+ ("Streaming", Color::Yellow)
+ };
+ metadata_lines.push(Line::from(vec![
+ Span::styled("Source: ", Style::default().fg(Color::Green)),
+ Span::styled(cache_status.0, Style::default().fg(cache_status.1)),
+ ]));
+
+ // File info if cached
+ if episode.is_cached {
+ if let Some(local_path) = &episode.local_path {
+ let filename = std::path::Path::new(local_path)
+ .file_name()
+ .unwrap_or_default()
+ .to_string_lossy();
+ metadata_lines.push(Line::from(vec![
+ Span::styled("File: ", Style::default().fg(Color::Green)),
+ Span::raw(filename.to_string()),
+ ]));
+ }
+ }
+
+ metadata_lines.push(Line::from(""));
+
+ // Playback position
+ let position_text = app.player.get_position_formatted();
+ let duration_text = app.player.get_duration_formatted();
+ metadata_lines.push(Line::from(vec![
+ Span::styled("Position: ", Style::default().fg(Color::Blue)),
+ Span::raw(format!("{} / {}", position_text, duration_text)),
+ ]));
+ }
+ }
+
+ let metadata_widget = Paragraph::new(metadata_lines)
+ .block(Block::default().title("Details").borders(Borders::ALL))
+ .wrap(Wrap { trim: true });
+ f.render_widget(metadata_widget, area);
+}
+
+fn render_episode_description(f: &mut Frame, app: &App, area: Rect) {
+ let mut description_text = "No description available.".to_string();
+
+ // Get episode description
+ if let Some(feed) = app.feeds.get(app.selected_feed) {
+ if let Some(episode) = feed.episodes.get(app.selected_episode) {
+ description_text = episode.description.clone();
+
+ // Clean up HTML tags if present
+ description_text = clean_html_tags(&description_text);
+
+ // Limit length for display
+ if description_text.len() > 800 {
+ description_text.truncate(800);
+ description_text.push_str("...");
+ }
+ }
+ }
+
+ let description_widget = Paragraph::new(description_text)
+ .style(Style::default().fg(Color::White))
+ .block(Block::default().title("Episode Description").borders(Borders::ALL))
+ .wrap(Wrap { trim: true });
+ f.render_widget(description_widget, area);
+}
+
+fn render_progress_and_controls(f: &mut Frame, app: &App, area: Rect) {
+ let progress_chunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Percentage(30), Constraint::Percentage(40), Constraint::Percentage(30)])
+ .split(area);
+
+ // Volume gauge
+ let volume_gauge = Gauge::default()
+ .block(Block::default().title("Volume").borders(Borders::ALL))
+ .gauge_style(Style::default().fg(Color::Cyan))
+ .percent(app.volume as u16)
+ .label(format!("{}%", app.volume));
+ f.render_widget(volume_gauge, progress_chunks[0]);
+
+ // Progress bar with visual indicator
+ let position = app.player.get_position();
+ let progress_text = format!("{} / {}",
+ app.player.get_position_formatted(),
+ app.player.get_duration_formatted()
+ );
+
+ // Create a simple text-based progress bar if we have duration
+ let progress_display = if let Some(duration) = app.player.get_duration() {
+ let progress_percentage = ((position / duration) * 100.0) as u16;
+ let progress_gauge = Gauge::default()
+ .block(Block::default().title("Progress").borders(Borders::ALL))
+ .gauge_style(Style::default().fg(Color::Yellow))
+ .percent(progress_percentage.min(100))
+ .label(progress_text);
+ f.render_widget(progress_gauge, progress_chunks[1]);
+ return;
+ } else {
+ Paragraph::new(progress_text)
+ .style(Style::default().fg(Color::Yellow))
+ .block(Block::default().title("Progress").borders(Borders::ALL))
+ .alignment(ratatui::layout::Alignment::Center)
+ };
+
+ f.render_widget(progress_display, progress_chunks[1]);
+
+ // Controls help with seeking info
+ let controls_text = if app.player.is_paused() {
+ "⏸ PAUSED | SPACE:play | s:stop | ±:vol | j/l:±15s | J/L:±1m | ESC:back | q:quit"
+ } else {
+ "▶ PLAYING | SPACE:pause | s:stop | ±:vol | j/l:±15s | J/L:±1m | ESC:back | q:quit"
+ };
+ let controls_widget = Paragraph::new(controls_text)
+ .style(Style::default().fg(Color::DarkGray))
+ .block(Block::default().title("Controls").borders(Borders::ALL))
+ .wrap(Wrap { trim: true });
+ f.render_widget(controls_widget, progress_chunks[2]);
+}
+
+fn get_episode_status_info(app: &App) -> String {
+ let playback_status = if app.player.is_paused() {
+ "⏸ Paused"
+ } else {
+ "▶ Playing"
+ };
+
+ let cache_status = if let Some(feed) = app.feeds.get(app.selected_feed) {
+ if let Some(episode) = feed.episodes.get(app.selected_episode) {
+ if episode.is_cached {
+ "💾 Local"
+ } else {
+ "🌐 Stream"
+ }
+ } else {
+ "❓ Unknown"
+ }
+ } else {
+ "❓ Unknown"
+ };
+
+ format!("{}\n{}", playback_status, cache_status)
+}
+
+fn truncate_string(s: &str, max_len: usize) -> String {
+ if s.len() <= max_len {
+ s.to_string()
+ } else {
+ format!("{}...", &s[..max_len.saturating_sub(3)])
+ }
+}
+
+
+fn clean_html_tags(html: &str) -> String {
+ // Simple HTML tag removal (basic implementation)
+ let mut result = String::new();
+ let mut in_tag = false;
+
+ for ch in html.chars() {
+ match ch {
+ '<' => in_tag = true,
+ '>' => in_tag = false,
+ _ if !in_tag => result.push(ch),
+ _ => {}
+ }
+ }
+
+ // Clean up extra whitespace
+ result
+ .split_whitespace()
+ .collect::<Vec<&str>>()
+ .join(" ")
+}
\ No newline at end of file