Commit 99b5dd9
src/app.rs
@@ -1,9 +1,5 @@
+use crate::{config::Config, feed::Feed, player::Player};
use anyhow::Result;
-use crate::{
- config::Config,
- feed::Feed,
- player::Player,
-};
use std::sync::mpsc;
use std::thread;
@@ -31,33 +27,34 @@ pub struct CurrentTrack {
pub title: String,
pub podcast_name: String,
pub _duration: Option<String>,
- pub _position: f64, // seconds
+ pub _position: f64, // seconds
}
impl App {
pub fn new() -> Result<Self> {
let config = Config::load()?;
- let feeds: Vec<Feed> = config.feeds
+ let feeds: Vec<Feed> = config
+ .feeds
.iter()
.map(|(name, url)| Feed::new(name.clone(), url.clone()))
.collect();
-
+
// 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 {
current_screen: CurrentScreen::FeedList,
feeds,
@@ -66,7 +63,7 @@ impl App {
_config: config,
player: Player::new()?,
current_track: None,
- volume: 70, // Default volume 70%
+ volume: 70, // Default volume 70%
feed_receiver: receiver,
})
}
@@ -140,10 +137,10 @@ impl App {
// Download and cache if not cached
self.player.play_with_cache(
&episode.enclosure_url,
- Some((&feed.name, &episode.title))
+ Some((&feed.name, &episode.title)),
)?;
}
-
+
// Set current track info and switch to NowPlaying screen
self.current_track = Some(CurrentTrack {
title: episode.title.clone(),
@@ -181,19 +178,19 @@ impl App {
}
pub fn seek_forward(&mut self) -> Result<()> {
- self.player.skip_forward(15.0) // 15 seconds forward (common podcast increment)
+ self.player.skip_forward(15.0) // 15 seconds forward (common podcast increment)
}
pub fn seek_backward(&mut self) -> Result<()> {
- self.player.skip_backward(15.0) // 15 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
+ 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
+ self.player.skip_backward(60.0) // 1 minute backward
}
pub fn stop_playback(&mut self) {
@@ -215,5 +212,4 @@ impl App {
}
}
}
-
-}
\ No newline at end of file
+}
src/config.rs
@@ -14,7 +14,7 @@ pub struct Config {
impl Config {
pub fn load() -> Result<Self> {
let config_path = Self::config_path()?;
-
+
if !config_path.exists() {
// Create a default config
let default_config = Self::default();
@@ -24,10 +24,13 @@ impl Config {
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config file: {:?}", config_path))?;
-
+
eprintln!("Loading config from: {:?}", config_path);
- eprintln!("Config content preview: {}", &content[0..content.len().min(200)]);
-
+ eprintln!(
+ "Config content preview: {}",
+ &content[0..content.len().min(200)]
+ );
+
let config: Config = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {:?}", config_path))?;
@@ -41,14 +44,13 @@ impl Config {
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path()?;
-
+
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {:?}", parent))?;
}
- let content = serde_yaml::to_string(self)
- .with_context(|| "Failed to serialize config")?;
+ let content = serde_yaml::to_string(self).with_context(|| "Failed to serialize config")?;
fs::write(&config_path, content)
.with_context(|| format!("Failed to write config file: {:?}", config_path))?;
@@ -64,7 +66,7 @@ impl Config {
return Ok(path);
}
}
-
+
// Then check ~/.config/ghetto-blaster.yml (Unix standard)
if let Some(home_dir) = dirs::home_dir() {
let path = home_dir.join(".config").join("ghetto-blaster.yml");
@@ -72,11 +74,10 @@ impl Config {
return Ok(path);
}
}
-
+
// Fall back to platform-specific config directory
- let config_dir = dirs::config_dir()
- .with_context(|| "Failed to find config directory")?;
-
+ let config_dir = dirs::config_dir().with_context(|| "Failed to find config directory")?;
+
Ok(config_dir.join("ghetto-blaster.yml"))
}
}
@@ -106,9 +107,7 @@ impl Default for Config {
Self {
feeds,
radio: Some(radio),
- music_dirs: Some(vec![
- "~/Music".to_string(),
- ]),
+ music_dirs: Some(vec!["~/Music".to_string()]),
}
}
-}
\ No newline at end of file
+}
src/feed.rs
@@ -72,7 +72,7 @@ impl Feed {
pub fn fetch_episodes(&mut self) -> Result<()> {
self.state = FeedState::Loading;
-
+
match self.try_fetch_episodes() {
Ok(()) => {
self.state = FeedState::Loaded;
@@ -89,19 +89,22 @@ impl Feed {
let response = reqwest::blocking::get(&self.url)
.with_context(|| format!("Failed to fetch feed: {}", self.url))?;
- let content = response.text()
+ let content = response
+ .text()
.with_context(|| "Failed to read response body")?;
- let rss: Rss = from_str(&content)
- .with_context(|| "Failed to parse RSS feed")?;
+ let rss: Rss = from_str(&content).with_context(|| "Failed to parse RSS feed")?;
- self.episodes = rss.channel.items
+ self.episodes = rss
+ .channel
+ .items
.into_iter()
.filter_map(|item| self.parse_episode(item))
.collect();
// Sort episodes by date (newest first)
- self.episodes.sort_by(|a, b| b.published_at.cmp(&a.published_at));
+ self.episodes
+ .sort_by(|a, b| b.published_at.cmp(&a.published_at));
// Scan for cached episodes and merge them
self.scan_and_merge_cached_episodes();
@@ -112,12 +115,13 @@ impl Feed {
fn scan_and_merge_cached_episodes(&mut self) {
// First, mark RSS episodes that are cached
self.mark_cached_episodes();
-
+
// Then, add cache-only episodes that aren't in RSS
self.add_cached_only_episodes();
-
+
// Sort again after merging
- self.episodes.sort_by(|a, b| b.published_at.cmp(&a.published_at));
+ self.episodes
+ .sort_by(|a, b| b.published_at.cmp(&a.published_at));
}
fn mark_cached_episodes(&mut self) {
@@ -127,7 +131,9 @@ impl Feed {
}
// Collect cache paths first to avoid borrowing issues
- let cache_paths: Vec<_> = self.episodes.iter()
+ let cache_paths: Vec<_> = self
+ .episodes
+ .iter()
.map(|ep| self.get_cache_path_for_url(&ep.enclosure_url))
.collect();
@@ -152,7 +158,10 @@ impl Feed {
// Check if this cached file is already represented in RSS episodes
let filename = path.file_name().unwrap().to_string_lossy();
let already_exists = self.episodes.iter().any(|ep| {
- ep.local_path.as_ref().map(|p| p.contains(&*filename)).unwrap_or(false)
+ ep.local_path
+ .as_ref()
+ .map(|p| p.contains(&*filename))
+ .unwrap_or(false)
});
if !already_exists {
@@ -175,8 +184,15 @@ impl Feed {
let cache_dir = self.get_cache_directory();
let filename = url.split('/').last().unwrap_or("episode.mp3");
let filename = filename.split('?').next().unwrap_or(filename);
- let safe_filename = filename.chars()
- .map(|c| if c.is_alphanumeric() || c == '.' || c == '-' { c } else { '_' })
+ let safe_filename = filename
+ .chars()
+ .map(|c| {
+ if c.is_alphanumeric() || c == '.' || c == '-' {
+ c
+ } else {
+ '_'
+ }
+ })
.collect::<String>();
cache_dir.join(safe_filename)
}
@@ -193,8 +209,9 @@ impl Feed {
fn create_cached_episode(&self, path: &PathBuf) -> Option<Episode> {
let filename = path.file_stem()?.to_string_lossy();
let title = filename.replace('_', " ").replace('-', " ");
-
- let published_at = path.metadata()
+
+ let published_at = path
+ .metadata()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
@@ -215,8 +232,9 @@ impl Feed {
fn parse_episode(&self, item: RssItem) -> Option<Episode> {
let title = item.title?;
let enclosure_url = item.enclosure?.url;
-
- let published_at = item.pub_date
+
+ let published_at = item
+ .pub_date
.and_then(|date_str| {
// Try parsing RFC 2822 format first
DateTime::parse_from_rfc2822(&date_str)
@@ -235,4 +253,4 @@ impl Feed {
local_path: None,
})
}
-}
\ No newline at end of file
+}
src/main.rs
@@ -34,13 +34,13 @@ fn main() -> Result<()> {
// Try different approach for macOS compatibility
let mut stdout = io::stdout();
-
+
// Set up panic handler to cleanup terminal
std::panic::set_hook(Box::new(|_| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
}));
-
+
// Try to enable raw mode with a fallback
let raw_mode_enabled = match enable_raw_mode() {
Ok(()) => true,
@@ -56,7 +56,7 @@ fn main() -> Result<()> {
false
}
};
-
+
if !raw_mode_enabled {
// Simple mode - just show the config and exit
println!("\n=== GHETTO-BLASTER CONFIG TEST ===");
@@ -69,12 +69,11 @@ fn main() -> Result<()> {
println!("\nTo use the full TUI interface, run this app in a proper terminal.");
return Ok(());
}
-
+
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
.with_context(|| "Failed to setup terminal screen")?;
let backend = CrosstermBackend::new(stdout);
- let mut terminal = Terminal::new(backend)
- .with_context(|| "Failed to create terminal")?;
+ let mut terminal = Terminal::new(backend).with_context(|| "Failed to create terminal")?;
// Main loop
let res = run_app(&mut terminal, &mut app);
@@ -99,45 +98,45 @@ 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))?;
// Use poll instead of read to avoid blocking, allowing for regular UI updates
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
match app.current_screen {
- CurrentScreen::FeedList => match key.code {
- KeyCode::Char('q') => return Ok(()),
- KeyCode::Char('j') | KeyCode::Down => app.next_feed(),
- KeyCode::Char('k') | KeyCode::Up => app.previous_feed(),
- KeyCode::Enter => app.select_feed(),
- KeyCode::Char('r') => app.refresh_feeds()?,
- _ => {}
- },
- CurrentScreen::EpisodeList => match key.code {
- KeyCode::Char('q') => return Ok(()),
- KeyCode::Char('h') | KeyCode::Left | KeyCode::Esc => app.back_to_feeds(),
- KeyCode::Char('j') | KeyCode::Down => app.next_episode(),
- KeyCode::Char('k') | KeyCode::Up => app.previous_episode(),
- KeyCode::Enter | KeyCode::Char(' ') => app.play_episode()?,
- _ => {}
- },
- CurrentScreen::NowPlaying => match key.code {
- KeyCode::Char('q') => return Ok(()),
- KeyCode::Char('h') | KeyCode::Left | KeyCode::Esc => app.back_to_episodes(),
- KeyCode::Char(' ') => app.toggle_playback()?,
- 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()?,
- _ => {}
- },
- }
+ CurrentScreen::FeedList => match key.code {
+ KeyCode::Char('q') => return Ok(()),
+ KeyCode::Char('j') | KeyCode::Down => app.next_feed(),
+ KeyCode::Char('k') | KeyCode::Up => app.previous_feed(),
+ KeyCode::Enter => app.select_feed(),
+ KeyCode::Char('r') => app.refresh_feeds()?,
+ _ => {}
+ },
+ CurrentScreen::EpisodeList => match key.code {
+ KeyCode::Char('q') => return Ok(()),
+ KeyCode::Char('h') | KeyCode::Left | KeyCode::Esc => app.back_to_feeds(),
+ KeyCode::Char('j') | KeyCode::Down => app.next_episode(),
+ KeyCode::Char('k') | KeyCode::Up => app.previous_episode(),
+ KeyCode::Enter | KeyCode::Char(' ') => app.play_episode()?,
+ _ => {}
+ },
+ CurrentScreen::NowPlaying => match key.code {
+ KeyCode::Char('q') => return Ok(()),
+ KeyCode::Char('h') | KeyCode::Left | KeyCode::Esc => app.back_to_episodes(),
+ KeyCode::Char(' ') => app.toggle_playback()?,
+ 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()?,
+ _ => {}
+ },
+ }
}
}
}
@@ -156,7 +155,9 @@ fn ui(f: &mut Frame, app: &App) {
.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::Loaded => {
+ format!("✓ {} ({})", feed.name, feed.episodes.len())
+ }
crate::feed::FeedState::Error(_) => format!("✗ {}", feed.name),
};
ListItem::new(Line::from(display_name))
@@ -180,19 +181,33 @@ 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 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!
Edit ~/.config/ghetto-blaster.yml
to add podcast feeds.
-Then press 'r' to refresh.".to_string()
+Then press 'r' to refresh."
+ .to_string()
} else if loading_count > 0 {
- format!("Loading feeds... ({} loading, {} loaded, {} errors)
+ format!(
+ "Loading feeds... ({} loading, {} loaded, {} errors)
⟳ = Loading
✓ = Loaded (episode count)
@@ -202,15 +217,18 @@ Navigation:
• j/k or ↑/↓ - navigate
• Enter - select feed
• r - refresh feeds
-• q - quit", loading_count, loaded_count, error_count)
+• q - quit",
+ loading_count, loaded_count, error_count
+ )
} else {
"Navigation:
• j/k or ↑/↓ - navigate
• Enter - select feed
• r - refresh feeds
-• q - quit".to_string()
+• q - quit"
+ .to_string()
};
-
+
let info = Paragraph::new(info_text)
.block(Block::default().title("Help").borders(Borders::ALL));
f.render_widget(info, chunks[1]);
@@ -223,7 +241,7 @@ Navigation:
.map(|ep| {
let cache_indicator = if ep.is_cached { "💾 " } else { "🌐 " };
let title_with_cache = format!("{}{}", cache_indicator, ep.title);
-
+
ListItem::new(Line::from(vec![
Span::raw(title_with_cache),
Span::styled(
@@ -258,4 +276,3 @@ Navigation:
}
}
}
-
src/player.rs
@@ -23,9 +23,9 @@ pub struct Player {
impl Player {
pub fn new() -> Result<Self> {
- let (stream, stream_handle) = OutputStream::try_default()
- .with_context(|| "Failed to create audio output stream")?;
-
+ let (stream, stream_handle) =
+ OutputStream::try_default().with_context(|| "Failed to create audio output stream")?;
+
Ok(Self {
_stream: stream,
stream_handle,
@@ -42,7 +42,6 @@ impl Player {
})
}
-
pub fn _play(&mut self, url: &str) -> Result<()> {
self.play_with_cache(url, None)
}
@@ -55,7 +54,12 @@ impl Player {
self.play_from_source(file_path, None, true)
}
- fn play_from_source(&mut self, source: &str, cache_info: Option<(&str, &str)>, is_local_file: bool) -> Result<()> {
+ fn play_from_source(
+ &mut self,
+ source: &str,
+ cache_info: Option<(&str, &str)>,
+ is_local_file: bool,
+ ) -> Result<()> {
// Stop any currently playing audio
self.stop();
@@ -89,7 +93,11 @@ impl Player {
Ok(())
}
- fn start_playback_from_position(&mut self, audio_data: &[u8], position_seconds: f64) -> Result<()> {
+ 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.to_vec());
@@ -102,7 +110,7 @@ impl Player {
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() {
@@ -112,8 +120,8 @@ impl Player {
}
// Create a new sink
- let sink = Sink::try_new(&self.stream_handle)
- .with_context(|| "Failed to create audio sink")?;
+ let sink =
+ Sink::try_new(&self.stream_handle).with_context(|| "Failed to create audio sink")?;
// Set volume
sink.set_volume(self.volume);
@@ -135,7 +143,8 @@ impl Player {
let response = reqwest::blocking::get(url)
.with_context(|| format!("Failed to fetch audio from URL: {}", url))?;
- let audio_data = response.bytes()
+ let audio_data = response
+ .bytes()
.with_context(|| "Failed to read audio data")?;
Ok(audio_data.to_vec())
@@ -144,22 +153,29 @@ impl Player {
fn cache_path(&self, feed_name: &str, _episode_title: &str, url: &str) -> PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
let music_dir = home.join("Music").join(feed_name);
-
+
// Create filename from URL, handling query parameters
let filename = url.split('/').last().unwrap_or("episode.mp3");
let filename = filename.split('?').next().unwrap_or(filename);
-
+
// Sanitize filename
- let safe_filename = filename.chars()
- .map(|c| if c.is_alphanumeric() || c == '.' || c == '-' { c } else { '_' })
+ let safe_filename = filename
+ .chars()
+ .map(|c| {
+ if c.is_alphanumeric() || c == '.' || c == '-' {
+ c
+ } else {
+ '_'
+ }
+ })
.collect::<String>();
-
+
music_dir.join(safe_filename)
}
fn load_from_cache(&self, feed_name: &str, episode_title: &str, url: &str) -> Result<Vec<u8>> {
let cache_path = self.cache_path(feed_name, episode_title, url);
-
+
if cache_path.exists() && cache_path.metadata()?.len() > 0 {
fs::read(&cache_path)
.with_context(|| format!("Failed to read cached file: {}", cache_path.display()))
@@ -168,20 +184,26 @@ impl Player {
}
}
- fn download_and_cache(&self, url: &str, feed_name: &str, episode_title: &str) -> Result<Vec<u8>> {
+ fn download_and_cache(
+ &self,
+ url: &str,
+ feed_name: &str,
+ episode_title: &str,
+ ) -> Result<Vec<u8>> {
let audio_data = self.download_audio(url)?;
let cache_path = self.cache_path(feed_name, episode_title, url);
-
+
// Create directory if it doesn't exist
if let Some(parent) = cache_path.parent() {
- fs::create_dir_all(parent)
- .with_context(|| format!("Failed to create cache directory: {}", parent.display()))?;
+ fs::create_dir_all(parent).with_context(|| {
+ format!("Failed to create cache directory: {}", parent.display())
+ })?;
}
-
+
// Write to cache file
fs::write(&cache_path, &audio_data)
.with_context(|| format!("Failed to write cache file: {}", cache_path.display()))?;
-
+
Ok(audio_data)
}
@@ -236,7 +258,7 @@ impl Player {
// 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)
}
@@ -246,23 +268,23 @@ impl Player {
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(())
}
@@ -271,7 +293,7 @@ impl Player {
self.seek(seconds)
}
- // Jump backward by a specific amount (e.g., 15 seconds)
+ // Jump backward by a specific amount (e.g., 15 seconds)
pub fn skip_backward(&mut self, seconds: f64) -> Result<()> {
self.seek(-seconds)
}
@@ -293,7 +315,7 @@ impl Player {
// 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 {
@@ -313,7 +335,7 @@ impl Player {
format_duration(self.get_position())
}
- // Get duration as formatted time string
+ // Get duration as formatted time string
pub fn get_duration_formatted(&self) -> String {
if let Some(duration) = self.get_duration() {
format_duration(duration)
@@ -353,4 +375,3 @@ fn format_duration(seconds: f64) -> String {
format!("{}:{:02}", minutes, secs)
}
}
-
src/podcast_ui.rs
@@ -13,16 +13,20 @@ pub fn render_now_playing_enhanced(f: &mut Frame, app: &App, area: Rect) {
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
+ 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))
+ .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]);
@@ -78,7 +82,12 @@ fn render_episode_details(f: &mut Frame, app: &App, _track: &CurrentTrack, area:
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(Span::styled(
+ "Episode Metadata",
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD),
+ )),
Line::from(""),
];
@@ -126,7 +135,7 @@ fn render_episode_metadata(f: &mut Frame, app: &App, area: Rect) {
}
metadata_lines.push(Line::from(""));
-
+
// Playback position
let position_text = app.player.get_position_formatted();
let duration_text = app.player.get_duration_formatted();
@@ -150,10 +159,10 @@ fn render_episode_description(f: &mut Frame, app: &App, area: Rect) {
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);
@@ -164,7 +173,11 @@ fn render_episode_description(f: &mut Frame, app: &App, area: Rect) {
let description_widget = Paragraph::new(description_text)
.style(Style::default().fg(Color::White))
- .block(Block::default().title("Episode Description").borders(Borders::ALL))
+ .block(
+ Block::default()
+ .title("Episode Description")
+ .borders(Borders::ALL),
+ )
.wrap(Wrap { trim: true });
f.render_widget(description_widget, area);
}
@@ -172,7 +185,11 @@ fn render_episode_description(f: &mut Frame, app: &App, area: Rect) {
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)])
+ .constraints([
+ Constraint::Percentage(30),
+ Constraint::Percentage(40),
+ Constraint::Percentage(30),
+ ])
.split(area);
// Volume gauge
@@ -185,11 +202,12 @@ fn render_progress_and_controls(f: &mut Frame, app: &App, area: Rect) {
// Progress bar with visual indicator
let position = app.player.get_position();
- let progress_text = format!("{} / {}",
+ 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;
@@ -206,7 +224,7 @@ fn render_progress_and_controls(f: &mut Frame, app: &App, area: Rect) {
.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
@@ -254,12 +272,11 @@ fn truncate_string(s: &str, max_len: usize) -> String {
}
}
-
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,
@@ -268,10 +285,7 @@ fn clean_html_tags(html: &str) -> String {
_ => {}
}
}
-
+
// Clean up extra whitespace
- result
- .split_whitespace()
- .collect::<Vec<&str>>()
- .join(" ")
-}
\ No newline at end of file
+ result.split_whitespace().collect::<Vec<&str>>().join(" ")
+}