main
1use anyhow::{Context, Result};
2use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink, Source};
3use std::fs;
4use std::io::Cursor;
5use std::path::PathBuf;
6use std::sync::{Arc, Mutex};
7use std::time::Instant;
8
9pub struct Player {
10 _stream: OutputStream,
11 stream_handle: OutputStreamHandle,
12 sink: Option<Arc<Mutex<Sink>>>,
13 start_time: Option<Instant>,
14 pause_time: Option<Instant>,
15 paused_duration: std::time::Duration,
16 seek_position: f64, // Seconds
17 is_paused: bool,
18 volume: f32,
19 current_source: Option<Vec<u8>>, // Store audio data for seeking
20 current_cache_info: Option<(String, String)>, // For re-loading
21 current_url: Option<String>,
22}
23
24impl Player {
25 pub fn new() -> Result<Self> {
26 let (stream, stream_handle) =
27 OutputStream::try_default().with_context(|| "Failed to create audio output stream")?;
28
29 Ok(Self {
30 _stream: stream,
31 stream_handle,
32 sink: None,
33 start_time: None,
34 pause_time: None,
35 paused_duration: std::time::Duration::ZERO,
36 seek_position: 0.0,
37 is_paused: false,
38 volume: 0.7, // 70% volume
39 current_source: None,
40 current_cache_info: None,
41 current_url: None,
42 })
43 }
44
45 pub fn _play(&mut self, url: &str) -> Result<()> {
46 self.play_with_cache(url, None)
47 }
48
49 pub fn play_with_cache(&mut self, url: &str, cache_info: Option<(&str, &str)>) -> Result<()> {
50 self.play_from_source(url, cache_info, false)
51 }
52
53 pub fn play_from_local_file(&mut self, file_path: &str) -> Result<()> {
54 self.play_from_source(file_path, None, true)
55 }
56
57 fn play_from_source(
58 &mut self,
59 source: &str,
60 cache_info: Option<(&str, &str)>,
61 is_local_file: bool,
62 ) -> Result<()> {
63 // Stop any currently playing audio
64 self.stop();
65
66 let audio_data = if is_local_file {
67 // Read directly from local file
68 fs::read(source)
69 .with_context(|| format!("Failed to read local audio file: {}", source))?
70 } else if let Some((feed_name, episode_title)) = cache_info {
71 // Try to load from cache first
72 if let Ok(cached_data) = self.load_from_cache(feed_name, episode_title, source) {
73 cached_data
74 } else {
75 // Download and cache
76 self.download_and_cache(source, feed_name, episode_title)?
77 }
78 } else {
79 // Stream directly without caching
80 self.download_audio(source)?
81 };
82
83 // Store audio data and metadata for seeking
84 self.current_source = Some(audio_data.clone());
85 self.current_cache_info = cache_info.map(|(f, e)| (f.to_string(), e.to_string()));
86 self.current_url = Some(source.to_string());
87 self.seek_position = 0.0;
88 self.paused_duration = std::time::Duration::ZERO;
89
90 // Start playback from the beginning
91 self.start_playback_from_position(&audio_data, 0.0)?;
92
93 Ok(())
94 }
95
96 fn start_playback_from_position(
97 &mut self,
98 audio_data: &[u8],
99 position_seconds: f64,
100 ) -> Result<()> {
101 // Create a cursor from the audio data
102 let cursor = Cursor::new(audio_data.to_vec());
103
104 // Decode the audio
105 let mut decoded_source = Decoder::new(cursor)
106 .with_context(|| "Failed to decode audio file. Unsupported format?")?;
107
108 // Skip to the desired position (approximate)
109 if position_seconds > 0.0 {
110 let sample_rate = decoded_source.sample_rate() as f64;
111 let channels = decoded_source.channels() as f64;
112 let samples_to_skip = (position_seconds * sample_rate * channels) as usize;
113
114 // Skip samples (this is approximate but works for seeking)
115 for _ in 0..samples_to_skip {
116 if decoded_source.next().is_none() {
117 break;
118 }
119 }
120 }
121
122 // Create a new sink
123 let sink =
124 Sink::try_new(&self.stream_handle).with_context(|| "Failed to create audio sink")?;
125
126 // Set volume
127 sink.set_volume(self.volume);
128
129 // Add the source to the sink
130 sink.append(decoded_source);
131
132 // Store the sink and adjust timing
133 self.sink = Some(Arc::new(Mutex::new(sink)));
134 self.start_time = Some(Instant::now());
135 self.seek_position = position_seconds;
136 self.is_paused = false;
137 self.pause_time = None;
138
139 Ok(())
140 }
141
142 fn download_audio(&self, url: &str) -> Result<Vec<u8>> {
143 let response = reqwest::blocking::get(url)
144 .with_context(|| format!("Failed to fetch audio from URL: {}", url))?;
145
146 let audio_data = response
147 .bytes()
148 .with_context(|| "Failed to read audio data")?;
149
150 Ok(audio_data.to_vec())
151 }
152
153 fn cache_path(&self, feed_name: &str, _episode_title: &str, url: &str) -> PathBuf {
154 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
155 let music_dir = home.join("Music").join(feed_name);
156
157 // Create filename from URL, handling query parameters
158 let filename = url.split('/').last().unwrap_or("episode.mp3");
159 let filename = filename.split('?').next().unwrap_or(filename);
160
161 // Sanitize filename
162 let safe_filename = filename
163 .chars()
164 .map(|c| {
165 if c.is_alphanumeric() || c == '.' || c == '-' {
166 c
167 } else {
168 '_'
169 }
170 })
171 .collect::<String>();
172
173 music_dir.join(safe_filename)
174 }
175
176 fn load_from_cache(&self, feed_name: &str, episode_title: &str, url: &str) -> Result<Vec<u8>> {
177 let cache_path = self.cache_path(feed_name, episode_title, url);
178
179 if cache_path.exists() && cache_path.metadata()?.len() > 0 {
180 fs::read(&cache_path)
181 .with_context(|| format!("Failed to read cached file: {}", cache_path.display()))
182 } else {
183 Err(anyhow::anyhow!("No valid cache file found"))
184 }
185 }
186
187 fn download_and_cache(
188 &self,
189 url: &str,
190 feed_name: &str,
191 episode_title: &str,
192 ) -> Result<Vec<u8>> {
193 let audio_data = self.download_audio(url)?;
194 let cache_path = self.cache_path(feed_name, episode_title, url);
195
196 // Create directory if it doesn't exist
197 if let Some(parent) = cache_path.parent() {
198 fs::create_dir_all(parent).with_context(|| {
199 format!("Failed to create cache directory: {}", parent.display())
200 })?;
201 }
202
203 // Write to cache file
204 fs::write(&cache_path, &audio_data)
205 .with_context(|| format!("Failed to write cache file: {}", cache_path.display()))?;
206
207 Ok(audio_data)
208 }
209
210 pub fn stop(&mut self) {
211 if let Some(sink) = &self.sink {
212 if let Ok(sink) = sink.lock() {
213 sink.stop();
214 }
215 }
216 self.sink = None;
217 self.start_time = None;
218 self.pause_time = None;
219 self.paused_duration = std::time::Duration::ZERO;
220 self.seek_position = 0.0;
221 self.is_paused = false;
222 // Keep current_source, current_cache_info, current_url for potential resume
223 }
224
225 pub fn toggle_pause(&mut self) -> Result<()> {
226 if let Some(sink) = &self.sink {
227 if let Ok(sink) = sink.lock() {
228 if self.is_paused {
229 // Resume: adjust start time to account for pause duration
230 if let Some(pause_time) = self.pause_time {
231 self.paused_duration += pause_time.elapsed();
232 }
233 sink.play();
234 self.is_paused = false;
235 self.pause_time = None;
236 } else {
237 // Pause: record when we paused
238 sink.pause();
239 self.is_paused = true;
240 self.pause_time = Some(Instant::now());
241 }
242 }
243 }
244 Ok(())
245 }
246
247 pub fn set_volume(&mut self, volume: u8) -> Result<()> {
248 self.volume = (volume as f32) / 100.0;
249 if let Some(sink) = &self.sink {
250 if let Ok(sink) = sink.lock() {
251 sink.set_volume(self.volume);
252 }
253 }
254 Ok(())
255 }
256
257 pub fn seek(&mut self, seconds: f64) -> Result<()> {
258 // Calculate new position
259 let current_pos = self.get_position();
260 let new_position = (current_pos + seconds).max(0.0);
261
262 // Seek to absolute position
263 self.seek_to(new_position)
264 }
265
266 pub fn seek_to(&mut self, position_seconds: f64) -> Result<()> {
267 // Can only seek if we have audio data loaded
268 if let Some(audio_data) = &self.current_source {
269 let audio_data = audio_data.clone();
270 let was_paused = self.is_paused;
271
272 // Stop current playback
273 if let Some(sink) = &self.sink {
274 if let Ok(sink) = sink.lock() {
275 sink.stop();
276 }
277 }
278
279 // Start playback from new position
280 self.start_playback_from_position(&audio_data, position_seconds)?;
281
282 // If we were paused, pause again
283 if was_paused {
284 self.toggle_pause()?;
285 }
286 }
287
288 Ok(())
289 }
290
291 // Jump forward by a specific amount (e.g., 15 seconds)
292 pub fn skip_forward(&mut self, seconds: f64) -> Result<()> {
293 self.seek(seconds)
294 }
295
296 // Jump backward by a specific amount (e.g., 15 seconds)
297 pub fn skip_backward(&mut self, seconds: f64) -> Result<()> {
298 self.seek(-seconds)
299 }
300
301 pub fn is_paused(&self) -> bool {
302 self.is_paused
303 }
304
305 pub fn get_position(&self) -> f64 {
306 if let Some(start_time) = self.start_time {
307 let elapsed = if self.is_paused {
308 // If paused, calculate time up to when we paused
309 if let Some(pause_time) = self.pause_time {
310 start_time.elapsed() - pause_time.elapsed()
311 } else {
312 start_time.elapsed()
313 }
314 } else {
315 // If playing, subtract total paused duration
316 start_time.elapsed() - self.paused_duration
317 };
318
319 // Add the seek position offset
320 self.seek_position + elapsed.as_secs_f64()
321 } else {
322 self.seek_position
323 }
324 }
325
326 // Get duration if available (this would require parsing the audio file)
327 pub fn get_duration(&self) -> Option<f64> {
328 // For now, return None - we could implement this by pre-parsing the audio file
329 // or storing duration from RSS feed metadata
330 None
331 }
332
333 // Get position as formatted time string
334 pub fn get_position_formatted(&self) -> String {
335 format_duration(self.get_position())
336 }
337
338 // Get duration as formatted time string
339 pub fn get_duration_formatted(&self) -> String {
340 if let Some(duration) = self.get_duration() {
341 format_duration(duration)
342 } else {
343 "--:--".to_string()
344 }
345 }
346
347 pub fn _is_playing(&self) -> bool {
348 if let Some(sink) = &self.sink {
349 if let Ok(sink) = sink.lock() {
350 !sink.empty() && !self.is_paused
351 } else {
352 false
353 }
354 } else {
355 false
356 }
357 }
358}
359
360impl Drop for Player {
361 fn drop(&mut self) {
362 self.stop();
363 }
364}
365
366fn format_duration(seconds: f64) -> String {
367 let total_seconds = seconds as u64;
368 let hours = total_seconds / 3600;
369 let minutes = (total_seconds % 3600) / 60;
370 let secs = total_seconds % 60;
371
372 if hours > 0 {
373 format!("{}:{:02}:{:02}", hours, minutes, secs)
374 } else {
375 format!("{}:{:02}", minutes, secs)
376 }
377}