Commit e29234a
Changed files (4)
src/app.rs
@@ -5,6 +5,7 @@ use crate::{config::Config, feed::Feed, player::Player};
pub enum CurrentScreen {
FeedList,
EpisodeList,
+ NowPlaying,
}
pub struct App {
@@ -14,6 +15,16 @@ pub struct App {
pub selected_episode: usize,
pub _config: Config,
pub player: Player,
+ pub current_track: Option<CurrentTrack>,
+ pub volume: u8,
+}
+
+#[derive(Debug, Clone)]
+pub struct CurrentTrack {
+ pub title: String,
+ pub podcast_name: String,
+ pub _duration: Option<String>,
+ pub _position: f64, // seconds
}
impl App {
@@ -37,7 +48,9 @@ impl App {
selected_feed: 0,
selected_episode: 0,
_config: config,
- player: Player::new(),
+ player: Player::new()?,
+ current_track: None,
+ volume: 70, // Default volume 70%
})
}
@@ -102,8 +115,58 @@ impl App {
if let Some(feed) = self.feeds.get(self.selected_feed) {
if let Some(episode) = feed.episodes.get(self.selected_episode) {
self.player.play(&episode.enclosure_url)?;
+
+ // Set current track info and switch to NowPlaying screen
+ self.current_track = Some(CurrentTrack {
+ title: episode.title.clone(),
+ podcast_name: feed.name.clone(),
+ _duration: episode._duration.clone(),
+ _position: 0.0,
+ });
+ self.current_screen = CurrentScreen::NowPlaying;
}
}
Ok(())
}
+
+ // Media control methods
+ pub fn toggle_playback(&mut self) -> Result<()> {
+ self.player.toggle_pause()
+ }
+
+ pub fn volume_up(&mut self) -> Result<()> {
+ if self.volume < 100 {
+ self.volume = (self.volume + 5).min(100);
+ self.player.set_volume(self.volume)
+ } else {
+ Ok(())
+ }
+ }
+
+ pub fn volume_down(&mut self) -> Result<()> {
+ if self.volume > 0 {
+ self.volume = self.volume.saturating_sub(5);
+ self.player.set_volume(self.volume)
+ } else {
+ Ok(())
+ }
+ }
+
+ pub fn seek_forward(&mut self) -> Result<()> {
+ self.player.seek(10.0) // 10 seconds forward
+ }
+
+ pub fn seek_backward(&mut self) -> Result<()> {
+ self.player.seek(-10.0) // 10 seconds backward
+ }
+
+ pub fn stop_playback(&mut self) {
+ self.player.stop();
+ self.current_track = None;
+ self.current_screen = CurrentScreen::EpisodeList;
+ }
+
+ pub fn back_to_episodes(&mut self) {
+ self.current_screen = CurrentScreen::EpisodeList;
+ }
}
\ No newline at end of file
src/main.rs
@@ -9,7 +9,7 @@ use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
- widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
+ widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph},
Frame, Terminal,
};
use std::io;
@@ -116,6 +116,17 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()>
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()?,
+ KeyCode::Char('l') | KeyCode::Right => app.seek_forward()?,
+ KeyCode::Char('j') | KeyCode::Down => app.seek_backward()?,
+ _ => {}
+ },
}
}
}
@@ -198,5 +209,75 @@ fn ui(f: &mut Frame, app: &App) {
f.render_stateful_widget(episodes_list, chunks[1], &mut episodes_state);
}
}
+ CurrentScreen::NowPlaying => {
+ render_now_playing(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,64 +1,145 @@
use anyhow::{Context, Result};
-use std::process::{Child, Command, Stdio};
+use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink};
+use std::io::Cursor;
+use std::sync::{Arc, Mutex};
+use std::time::Instant;
pub struct Player {
- current_process: Option<Child>,
+ _stream: OutputStream,
+ stream_handle: OutputStreamHandle,
+ sink: Option<Arc<Mutex<Sink>>>,
+ start_time: Option<Instant>,
+ is_paused: bool,
+ volume: f32,
}
impl Player {
- pub fn new() -> Self {
- Self {
- current_process: None,
- }
+ pub fn new() -> Result<Self> {
+ let (stream, stream_handle) = OutputStream::try_default()
+ .with_context(|| "Failed to create audio output stream")?;
+
+ Ok(Self {
+ _stream: stream,
+ stream_handle,
+ sink: None,
+ start_time: None,
+ is_paused: false,
+ volume: 0.7, // 70% volume
+ })
}
pub fn play(&mut self, url: &str) -> Result<()> {
// Stop any currently playing audio
self.stop();
- // Start mpv process
- let child = Command::new("mpv")
- .args(&[
- "--no-video",
- "--osc=yes",
- "--osd-level=1",
- url
- ])
- .stdin(Stdio::null())
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .spawn()
- .with_context(|| "Failed to start mpv. Make sure mpv is installed.")?;
-
- self.current_process = Some(child);
+
+
+ // Download the audio file
+ let response = reqwest::blocking::get(url)
+ .with_context(|| format!("Failed to fetch audio from URL: {}", url))?;
+
+ let audio_data = response.bytes()
+ .with_context(|| "Failed to read audio data")?;
+
+
+
+ // Create a cursor from the downloaded data
+ let cursor = Cursor::new(audio_data);
+
+ // Decode the audio
+ let source = Decoder::new(cursor)
+ .with_context(|| "Failed to decode audio file. Unsupported format?")?;
+
+
+
+ // Create a new sink
+ let sink = Sink::try_new(&self.stream_handle)
+ .with_context(|| "Failed to create audio sink")?;
+
+ // Set volume
+ sink.set_volume(self.volume);
+
+ // Add the source to the sink
+ sink.append(source);
+
+ // Store the sink and start time
+ self.sink = Some(Arc::new(Mutex::new(sink)));
+ self.start_time = Some(Instant::now());
+ self.is_paused = false;
+
+
Ok(())
}
pub fn stop(&mut self) {
- if let Some(mut process) = self.current_process.take() {
- let _ = process.kill();
- let _ = process.wait();
+ if let Some(sink) = &self.sink {
+ if let Ok(sink) = sink.lock() {
+ sink.stop();
+ }
}
+ self.sink = None;
+ self.start_time = None;
+ self.is_paused = false;
}
- pub fn _is_playing(&mut self) -> bool {
- if let Some(process) = &mut self.current_process {
- match process.try_wait() {
- Ok(Some(_)) => {
- // Process has exited
- self.current_process = None;
- false
- }
- Ok(None) => {
- // Process is still running
- true
- }
- Err(_) => {
- // Error checking process, assume it's dead
- self.current_process = None;
- false
+ pub fn toggle_pause(&mut self) -> Result<()> {
+ if let Some(sink) = &self.sink {
+ if let Ok(sink) = sink.lock() {
+ if self.is_paused {
+ sink.play();
+ self.is_paused = false;
+ } else {
+ sink.pause();
+ self.is_paused = true;
}
}
+ }
+ Ok(())
+ }
+
+ pub fn set_volume(&mut self, volume: u8) -> Result<()> {
+ self.volume = (volume as f32) / 100.0;
+ if let Some(sink) = &self.sink {
+ if let Ok(sink) = sink.lock() {
+ sink.set_volume(self.volume);
+ }
+ }
+ 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
+
+ Ok(())
+ }
+
+ 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()
+ } else {
+ start_time.elapsed().as_secs_f64()
+ }
+ } else {
+ 0.0
+ }
+ }
+
+ pub fn _is_playing(&self) -> bool {
+ if let Some(sink) = &self.sink {
+ if let Ok(sink) = sink.lock() {
+ !sink.empty() && !self.is_paused
+ } else {
+ false
+ }
} else {
false
}
Cargo.toml
@@ -26,7 +26,9 @@ anyhow = "1.0"
# TTY detection
atty = "0.2"
-# Audio playback (using std::process)
+# Audio playback (native Rust)
+rodio = "0.17"
+symphonia = { version = "0.5", features = ["mp3", "aac", "flac", "vorbis"] }
# Async runtime
tokio = { version = "1.0", features = ["full"] }