Skip to main content

plexus_mono/
player.rs

1//! Playback engine — dedicated audio thread with queue management
2//!
3//! All rodio interaction is isolated to a single OS thread (OutputStream is !Send).
4//! The Sink is Send+Sync and shared via Arc for control from async code.
5
6use std::collections::{HashMap, VecDeque};
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12use tokio::sync::{watch, Mutex};
13
14use crate::client::MonoClient;
15use crate::types::{MonoEvent, PlayStatus, QueuedTrack};
16
17/// Persisted player state — saved to disk so playback can resume across restarts
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct PlayerState {
20    pub current_track: Option<QueuedTrack>,
21    pub position_secs: f32,
22    pub queue: Vec<QueuedTrack>,
23    pub history: Vec<QueuedTrack>,
24    pub volume: f32,
25    #[serde(default = "default_preamp")]
26    pub preamp: f32,
27}
28
29fn default_preamp() -> f32 {
30    1.0
31}
32
33impl PlayerState {
34    fn state_path() -> PathBuf {
35        dirs::home_dir()
36            .unwrap_or_else(|| PathBuf::from("."))
37            .join(".plexus/monochrome/player/state.json")
38    }
39
40    pub fn load() -> Option<Self> {
41        let path = Self::state_path();
42        let data = std::fs::read_to_string(&path).ok()?;
43        serde_json::from_str(&data).ok()
44    }
45
46    pub fn save(&self) {
47        let path = Self::state_path();
48        if let Some(parent) = path.parent() {
49            let _ = std::fs::create_dir_all(parent);
50        }
51        if let Ok(json) = serde_json::to_string_pretty(self) {
52            let _ = std::fs::write(&path, json);
53        }
54    }
55}
56
57/// Helper trait to erase the concrete StreamDownload type behind Box.
58/// Rust doesn't allow `dyn Read + Seek + Send` (multiple non-auto traits),
59/// so we combine them into one trait and blanket-implement it.
60trait ReadSeekSend: std::io::Read + std::io::Seek + Send + Sync {}
61impl<T: std::io::Read + std::io::Seek + Send + Sync> ReadSeekSend for T {}
62
63/// Snapshot of current playback state, broadcast via watch channel
64#[derive(Debug, Clone)]
65pub struct NowPlaying {
66    pub track_id: Option<u64>,
67    pub title: Option<String>,
68    pub artist: Option<String>,
69    pub album: Option<String>,
70    pub status: PlayStatus,
71    pub position_secs: f32,
72    pub duration_secs: f32,
73    pub volume: f32,
74    pub preamp: f32,
75    pub queue_length: usize,
76    pub url: Option<String>,
77}
78
79impl Default for NowPlaying {
80    fn default() -> Self {
81        Self {
82            track_id: None,
83            title: None,
84            artist: None,
85            album: None,
86            status: PlayStatus::Idle,
87            position_secs: 0.0,
88            duration_secs: 0.0,
89            volume: 1.0,
90            preamp: 1.0,
91            queue_length: 0,
92            url: None,
93        }
94    }
95}
96
97struct PlayerInner {
98    queue: VecDeque<QueuedTrack>,
99    current_track: Option<QueuedTrack>,
100    status: PlayStatus,
101    volume: f32,
102    preamp: f32,
103    history: Vec<QueuedTrack>,
104    /// Pre-buffered audio readers keyed by track ID.
105    /// Each entry is a StreamDownload that's already connected and downloading.
106    /// Dropped automatically when removed (temp file cleaned up via RAII).
107    prefetched: HashMap<u64, Box<dyn ReadSeekSend>>,
108}
109
110/// Audio playback engine with queue and controls
111pub struct Player {
112    sink: Arc<rodio::Sink>,
113    inner: Mutex<PlayerInner>,
114    now_playing_tx: watch::Sender<NowPlaying>,
115    now_playing_rx: watch::Receiver<NowPlaying>,
116    client: Arc<MonoClient>,
117    // Dropping this signals the audio thread to exit
118    _shutdown_tx: std::sync::mpsc::Sender<()>,
119}
120
121impl Player {
122    /// Create a new Player. Spawns a dedicated audio thread and background watchers.
123    pub async fn new(client: Arc<MonoClient>) -> Arc<Self> {
124        let (sink_tx, sink_rx) = std::sync::mpsc::channel();
125        let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>();
126
127        std::thread::spawn(move || {
128            let (_stream, handle) = rodio::OutputStream::try_default()
129                .expect("failed to open default audio output device");
130            let sink = rodio::Sink::try_new(&handle)
131                .expect("failed to create audio sink");
132            let _ = sink_tx.send(sink);
133            // Keep _stream alive until Player is dropped
134            let _ = shutdown_rx.recv();
135        });
136
137        let sink = Arc::new(sink_rx.recv().expect("audio thread failed to initialize"));
138        sink.pause(); // Start idle
139
140        let (now_playing_tx, now_playing_rx) = watch::channel(NowPlaying::default());
141
142        let player = Arc::new(Self {
143            sink,
144            inner: Mutex::new(PlayerInner {
145                queue: VecDeque::new(),
146                current_track: None,
147                status: PlayStatus::Idle,
148                volume: 1.0,
149                preamp: 1.0,
150                history: Vec::new(),
151                prefetched: HashMap::new(),
152            }),
153            now_playing_tx,
154            now_playing_rx,
155            client,
156            _shutdown_tx: shutdown_tx,
157        });
158
159        // Position reporter (~1s updates while playing)
160        let weak = Arc::downgrade(&player);
161        tokio::spawn(async move {
162            loop {
163                tokio::time::sleep(Duration::from_secs(1)).await;
164                let Some(this) = weak.upgrade() else { break };
165                let is_playing = {
166                    let inner = this.inner.lock().await;
167                    matches!(inner.status, PlayStatus::Playing)
168                };
169                if is_playing {
170                    this.broadcast_now_playing().await;
171                }
172            }
173        });
174
175        // Track watcher — auto-advance when current track ends
176        let weak = Arc::downgrade(&player);
177        tokio::spawn(async move {
178            loop {
179                tokio::time::sleep(Duration::from_millis(250)).await;
180                let Some(this) = weak.upgrade() else { break };
181                if !this.sink.empty() {
182                    continue;
183                }
184                let mut inner = this.inner.lock().await;
185                if matches!(inner.status, PlayStatus::Playing) {
186                    // Track ended naturally
187                    if let Some(current) = inner.current_track.take() {
188                        inner.history.push(current);
189                    }
190                    if let Some(next) = inner.queue.pop_front() {
191                        inner.status = PlayStatus::Buffering;
192                        drop(inner);
193                        if let Err(e) = this.start_playback(next).await {
194                            tracing::error!("auto-advance failed: {e}");
195                            let mut inner = this.inner.lock().await;
196                            inner.status = PlayStatus::Idle;
197                            inner.current_track = None;
198                            drop(inner);
199                            this.broadcast_now_playing().await;
200                        }
201                        this.save_state().await;
202                    } else {
203                        inner.status = PlayStatus::Idle;
204                        inner.current_track = None;
205                        drop(inner);
206                        this.broadcast_now_playing().await;
207                        this.save_state().await;
208                    }
209                }
210            }
211        });
212
213        // Prefetch watcher — pre-buffers queued tracks when playing
214        let weak = Arc::downgrade(&player);
215        tokio::spawn(async move {
216            let mut last_track_id: Option<u64> = None;
217            loop {
218                tokio::time::sleep(Duration::from_secs(2)).await;
219                let Some(this) = weak.upgrade() else { break };
220                let current_id = {
221                    let inner = this.inner.lock().await;
222                    if !matches!(inner.status, PlayStatus::Playing) {
223                        continue;
224                    }
225                    inner.current_track.as_ref().map(|t| t.id)
226                };
227                // Prefetch when track changes or on first play
228                if current_id != last_track_id {
229                    last_track_id = current_id;
230                    this.prefetch_queue().await;
231                }
232            }
233        });
234
235        // OS media controls (play/pause keys, Now Playing widget)
236        player.setup_media_controls();
237
238        // Restore persisted state (queue, history, volume) from previous session
239        player.restore_state().await;
240
241        player
242    }
243
244    /// Wire up macOS Now Playing / media key integration via souvlaki.
245    /// Spawns a dedicated thread that owns MediaControls and polls for
246    /// metadata updates from the watch channel.
247    fn setup_media_controls(self: &Arc<Self>) {
248        use souvlaki::{
249            MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition,
250            PlatformConfig,
251        };
252
253        let tokio_handle = tokio::runtime::Handle::current();
254        let weak = Arc::downgrade(self);
255        let mut np_rx = self.subscribe_now_playing();
256
257        std::thread::Builder::new()
258            .name("media-controls".into())
259            .spawn(move || {
260                let config = PlatformConfig {
261                    dbus_name: "plexus_mono",
262                    display_name: "Plexus Mono",
263                    hwnd: None,
264                };
265                let mut controls = match MediaControls::new(config) {
266                    Ok(c) => c,
267                    Err(e) => {
268                        tracing::warn!("media controls unavailable: {e:?}");
269                        return;
270                    }
271                };
272
273                // Event handler — dispatches media key presses to player via tokio
274                let weak2 = weak.clone();
275                let handle = tokio_handle.clone();
276                if let Err(e) = controls.attach(move |event: MediaControlEvent| {
277                    let Some(player) = weak2.upgrade() else {
278                        return;
279                    };
280                    let player = player.clone();
281                    handle.spawn(async move {
282                        match event {
283                            MediaControlEvent::Play => player.resume().await,
284                            MediaControlEvent::Pause => player.pause().await,
285                            MediaControlEvent::Toggle => {
286                                let is_playing = {
287                                    let inner = player.inner.lock().await;
288                                    matches!(inner.status, PlayStatus::Playing)
289                                };
290                                if is_playing {
291                                    player.pause().await;
292                                } else {
293                                    player.resume().await;
294                                }
295                            }
296                            MediaControlEvent::Next => {
297                                let _ = player.next().await;
298                            }
299                            MediaControlEvent::Previous => {
300                                let _ = player.previous().await;
301                            }
302                            _ => {}
303                        }
304                    });
305                }) {
306                    tracing::warn!("failed to attach media controls: {e:?}");
307                    return;
308                }
309
310                tracing::info!("media controls active (Now Playing + media keys)");
311
312                // Set initial state immediately to claim media keys from macOS
313                let _ = controls.set_metadata(MediaMetadata {
314                    title: Some("Plexus Mono"),
315                    artist: None,
316                    album: None,
317                    duration: None,
318                    cover_url: None,
319                });
320                let _ = controls.set_playback(MediaPlayback::Paused { progress: None });
321
322                // Poll watch channel and update OS metadata
323                loop {
324                    std::thread::sleep(Duration::from_millis(500));
325
326                    if !np_rx.has_changed().unwrap_or(false) {
327                        // Also check if player is dropped
328                        if weak.upgrade().is_none() {
329                            break;
330                        }
331                        continue;
332                    }
333
334                    let np = np_rx.borrow_and_update().clone();
335
336                    // Build cover art URL from Tidal cover UUID
337                    let cover_url = np.title.as_ref().and_then(|_| {
338                        // We don't have cover_id in NowPlaying, so skip for now
339                        None::<String>
340                    });
341
342                    let _ = controls.set_metadata(MediaMetadata {
343                        title: np.title.as_deref(),
344                        artist: np.artist.as_deref(),
345                        album: np.album.as_deref(),
346                        duration: if np.duration_secs > 0.0 {
347                            Some(Duration::from_secs_f32(np.duration_secs))
348                        } else {
349                            None
350                        },
351                        cover_url: cover_url.as_deref(),
352                    });
353
354                    let playback = match np.status {
355                        PlayStatus::Playing => MediaPlayback::Playing {
356                            progress: Some(MediaPosition(Duration::from_secs_f32(
357                                np.position_secs,
358                            ))),
359                        },
360                        PlayStatus::Paused => MediaPlayback::Paused {
361                            progress: Some(MediaPosition(Duration::from_secs_f32(
362                                np.position_secs,
363                            ))),
364                        },
365                        _ => MediaPlayback::Stopped,
366                    };
367                    let _ = controls.set_playback(playback);
368                }
369            })
370            .expect("failed to spawn media-controls thread");
371    }
372
373    /// Broadcast current state through the watch channel
374    async fn broadcast_now_playing(&self) {
375        let inner = self.inner.lock().await;
376        let np = NowPlaying {
377            track_id: inner.current_track.as_ref().map(|t| t.id),
378            title: inner.current_track.as_ref().map(|t| t.title.clone()),
379            artist: inner.current_track.as_ref().map(|t| t.artist.clone()),
380            album: inner.current_track.as_ref().map(|t| t.album.clone()),
381            status: inner.status.clone(),
382            position_secs: self.sink.get_pos().as_secs_f32(),
383            duration_secs: inner
384                .current_track
385                .as_ref()
386                .map(|t| t.duration_secs as f32)
387                .unwrap_or(0.0),
388            volume: inner.volume,
389            preamp: inner.preamp,
390            queue_length: inner.queue.len(),
391            url: inner.current_track.as_ref().map(|t| format!("https://monochrome.tf/track/t/{}", t.id)),
392        };
393        let _ = self.now_playing_tx.send(np);
394    }
395
396    /// Resolve stream URL, create decoder, and start playback on the sink.
397    async fn start_playback(&self, track: QueuedTrack) -> Result<(), String> {
398        {
399            let mut inner = self.inner.lock().await;
400            inner.current_track = Some(track.clone());
401            inner.status = PlayStatus::Buffering;
402        }
403        self.broadcast_now_playing().await;
404
405        // Check for a pre-buffered reader first
406        let prefetched: Option<Box<dyn ReadSeekSend>> = {
407            let mut inner = self.inner.lock().await;
408            inner.prefetched.remove(&track.id)
409        };
410
411        let reader: Box<dyn ReadSeekSend> = if let Some(r) = prefetched {
412            tracing::debug!("using prefetched audio for track {}", track.id);
413            r
414        } else {
415            // Resolve CDN URL
416            let manifest = self.client.stream_manifest(track.id, &track.quality).await?;
417            let url = match &manifest {
418                MonoEvent::StreamManifest { url, .. } => url.clone(),
419                _ => return Err("unexpected manifest type".to_string()),
420            };
421
422            // Create streaming reader (async HTTP → Read+Seek buffer)
423            let r = stream_download::StreamDownload::new_http(
424                url.parse::<reqwest::Url>()
425                    .map_err(|e| format!("bad stream url: {e}"))?,
426                stream_download::storage::temp::TempStorageProvider::new(),
427                stream_download::Settings::default(),
428            )
429            .await
430            .map_err(|e| format!("stream download error: {e}"))?;
431            Box::new(r)
432        };
433
434        // Decode on blocking thread (reads file headers from network buffer)
435        let source = tokio::task::spawn_blocking(move || rodio::Decoder::new(reader))
436            .await
437            .map_err(|e| format!("decoder task panicked: {e}"))?
438            .map_err(|e| format!("audio decode error: {e}"))?;
439
440        // Stop previous audio, append new source, play
441        self.sink.stop();
442        self.sink.append(source);
443        self.sink.play();
444
445        {
446            let mut inner = self.inner.lock().await;
447            inner.status = PlayStatus::Playing;
448        }
449        self.broadcast_now_playing().await;
450
451        Ok(())
452    }
453
454    /// Play a track immediately, stopping whatever is currently playing.
455    pub async fn play_track(&self, id: u64, quality: &str) -> Result<(), String> {
456        let track_info = self.client.track_info(id).await.ok();
457        let queued = make_queued_track(id, quality, track_info);
458
459        // Move current to history
460        {
461            let mut inner = self.inner.lock().await;
462            if let Some(current) = inner.current_track.take() {
463                inner.history.push(current);
464            }
465        }
466
467        self.start_playback(queued).await
468    }
469
470    /// Pause playback
471    pub async fn pause(&self) {
472        self.sink.pause();
473        let mut inner = self.inner.lock().await;
474        if matches!(inner.status, PlayStatus::Playing | PlayStatus::Buffering) {
475            inner.status = PlayStatus::Paused;
476        }
477        drop(inner);
478        self.broadcast_now_playing().await;
479    }
480
481    /// Resume playback
482    pub async fn resume(&self) {
483        self.sink.play();
484        let mut inner = self.inner.lock().await;
485        if matches!(inner.status, PlayStatus::Paused) {
486            inner.status = PlayStatus::Playing;
487        }
488        drop(inner);
489        self.broadcast_now_playing().await;
490    }
491
492    /// Stop playback and clear current track
493    pub async fn stop(&self) {
494        self.sink.stop();
495        let mut inner = self.inner.lock().await;
496        if let Some(current) = inner.current_track.take() {
497            inner.history.push(current);
498        }
499        inner.status = PlayStatus::Stopped;
500        inner.prefetched.clear(); // Drop all pre-buffered temp files
501        drop(inner);
502        self.broadcast_now_playing().await;
503        self.save_state().await;
504    }
505
506    /// Skip to next track in queue
507    pub async fn next(&self) -> Result<(), String> {
508        self.sink.stop();
509        let next = {
510            let mut inner = self.inner.lock().await;
511            if let Some(current) = inner.current_track.take() {
512                inner.history.push(current);
513            }
514            inner.queue.pop_front()
515        };
516
517        if let Some(track) = next {
518            self.start_playback(track).await
519        } else {
520            let mut inner = self.inner.lock().await;
521            inner.status = PlayStatus::Idle;
522            drop(inner);
523            self.broadcast_now_playing().await;
524            Err("queue is empty".to_string())
525        }
526    }
527
528    /// Go to previous track (from history), or restart current if >5s in
529    pub async fn previous(&self) -> Result<(), String> {
530        // If we're more than 5 seconds into the current track, restart it
531        if self.sink.get_pos().as_secs_f32() > 5.0 {
532            let track = {
533                let inner = self.inner.lock().await;
534                inner.current_track.clone()
535            };
536            if let Some(track) = track {
537                return self.start_playback(track).await;
538            }
539        }
540
541        self.sink.stop();
542        let prev = {
543            let mut inner = self.inner.lock().await;
544            // Push current back to front of queue
545            if let Some(current) = inner.current_track.take() {
546                inner.queue.push_front(current);
547            }
548            inner.history.pop()
549        };
550
551        if let Some(track) = prev {
552            self.start_playback(track).await
553        } else {
554            Err("no previous track".to_string())
555        }
556    }
557
558    /// Apply combined volume (preamp × volume) to the sink
559    fn apply_volume(&self, inner: &PlayerInner) {
560        self.sink.set_volume(inner.preamp * inner.volume);
561    }
562
563    /// Set volume (0.0–1.0)
564    pub async fn set_volume(&self, level: f32) {
565        let level = level.clamp(0.0, 1.0);
566        let mut inner = self.inner.lock().await;
567        inner.volume = level;
568        self.apply_volume(&inner);
569        drop(inner);
570        self.broadcast_now_playing().await;
571        self.save_state().await;
572    }
573
574    /// Set pre-amp gain (0.0–4.0, where >1.0 boosts)
575    pub async fn set_preamp(&self, level: f32) {
576        let level = level.clamp(0.0, 4.0);
577        let mut inner = self.inner.lock().await;
578        inner.preamp = level;
579        self.apply_volume(&inner);
580        drop(inner);
581        self.broadcast_now_playing().await;
582        self.save_state().await;
583    }
584
585    /// Add a track to the end of the queue. Auto-starts if idle.
586    pub async fn queue_add(&self, id: u64, quality: &str) -> Result<(), String> {
587        let track_info = self.client.track_info(id).await.ok();
588        let queued = make_queued_track(id, quality, track_info);
589
590        let should_start = {
591            let mut inner = self.inner.lock().await;
592            let idle = matches!(inner.status, PlayStatus::Idle | PlayStatus::Stopped);
593            if idle {
594                // Will start this track directly
595                true
596            } else {
597                inner.queue.push_back(queued.clone());
598                false
599            }
600        };
601
602        let result = if should_start {
603            self.start_playback(queued).await
604        } else {
605            self.broadcast_now_playing().await;
606            Ok(())
607        };
608        self.save_state().await;
609        result
610    }
611
612    /// Add all tracks from an album to the queue. Auto-starts if idle.
613    pub async fn queue_album(&self, album_id: u64, quality: &str) -> Result<Vec<QueuedTrack>, String> {
614        let (_album_event, track_events) = self.client.album(album_id).await?;
615
616        let mut queued_tracks = Vec::new();
617        for event in &track_events {
618            if let MonoEvent::AlbumTrack { id, title, artist, duration_secs, .. } = event {
619                queued_tracks.push(QueuedTrack {
620                    id: *id,
621                    title: title.clone(),
622                    artist: artist.clone(),
623                    album: String::new(), // filled below
624                    duration_secs: *duration_secs,
625                    quality: quality.to_string(),
626                    cover_id: None,
627                });
628            }
629        }
630
631        // Get album name from the album event
632        let album_name = if let MonoEvent::Album { title, cover_id, .. } = &_album_event {
633            for t in &mut queued_tracks {
634                t.album = title.clone();
635                t.cover_id = cover_id.clone();
636            }
637            title.clone()
638        } else {
639            format!("Album {album_id}")
640        };
641
642        if queued_tracks.is_empty() {
643            return Err(format!("no tracks found in album {album_name}"));
644        }
645
646        let should_start = {
647            let mut inner = self.inner.lock().await;
648            let idle = matches!(inner.status, PlayStatus::Idle | PlayStatus::Stopped);
649            if idle {
650                // Queue all but the first; we'll start the first directly
651                for t in queued_tracks.iter().skip(1) {
652                    inner.queue.push_back(t.clone());
653                }
654                true
655            } else {
656                for t in &queued_tracks {
657                    inner.queue.push_back(t.clone());
658                }
659                false
660            }
661        };
662
663        if should_start {
664            self.start_playback(queued_tracks[0].clone()).await?;
665        } else {
666            self.broadcast_now_playing().await;
667        }
668
669        Ok(queued_tracks)
670    }
671
672    /// Add multiple tracks to the queue at once. Auto-starts if idle.
673    pub async fn queue_batch(&self, ids: &[u64], quality: &str) -> Result<Vec<QueuedTrack>, String> {
674        if ids.is_empty() {
675            return Err("no track IDs provided".into());
676        }
677
678        // Resolve all track metadata in parallel
679        let futs: Vec<_> = ids.iter().map(|&id| {
680            let client = self.client.clone();
681            let q = quality.to_string();
682            async move {
683                let info = client.track_info(id).await.ok();
684                make_queued_track(id, &q, info)
685            }
686        }).collect();
687        let tracks: Vec<QueuedTrack> = futures::future::join_all(futs).await;
688
689        let should_start = {
690            let mut inner = self.inner.lock().await;
691            let idle = matches!(inner.status, PlayStatus::Idle | PlayStatus::Stopped);
692            if idle {
693                // Queue all but the first; we'll start the first directly
694                for t in tracks.iter().skip(1) {
695                    inner.queue.push_back(t.clone());
696                }
697                true
698            } else {
699                for t in &tracks {
700                    inner.queue.push_back(t.clone());
701                }
702                false
703            }
704        };
705
706        if should_start {
707            self.start_playback(tracks[0].clone()).await?;
708        } else {
709            self.broadcast_now_playing().await;
710        }
711
712        self.save_state().await;
713        Ok(tracks)
714    }
715
716    /// Clear the queue (does not stop current track)
717    pub async fn queue_clear(&self) {
718        let mut inner = self.inner.lock().await;
719        inner.queue.clear();
720        inner.prefetched.clear(); // Drop all pre-buffered temp files
721        drop(inner);
722        self.broadcast_now_playing().await;
723    }
724
725    /// Get current track and queue contents
726    pub async fn queue_get(&self) -> (Option<QueuedTrack>, Vec<QueuedTrack>) {
727        let inner = self.inner.lock().await;
728        (
729            inner.current_track.clone(),
730            inner.queue.iter().cloned().collect(),
731        )
732    }
733
734    /// Reorder a track in the queue
735    pub async fn queue_reorder(&self, from: usize, to: usize) -> Result<(), String> {
736        let mut inner = self.inner.lock().await;
737        if from >= inner.queue.len() || to >= inner.queue.len() {
738            return Err(format!(
739                "index out of bounds (queue has {} tracks)",
740                inner.queue.len()
741            ));
742        }
743        let track = inner.queue.remove(from).unwrap();
744        inner.queue.insert(to, track);
745        Ok(())
746    }
747
748    /// Pre-buffer queued tracks by resolving their stream URLs and starting downloads.
749    /// Each StreamDownload writes to a temp file (cleaned up on drop via RAII).
750    async fn prefetch_queue(&self) {
751        let tracks: Vec<QueuedTrack> = {
752            let inner = self.inner.lock().await;
753            inner
754                .queue
755                .iter()
756                .filter(|t| !inner.prefetched.contains_key(&t.id))
757                .take(10)
758                .cloned()
759                .collect()
760        };
761
762        for track in tracks {
763            // Resolve manifest
764            let manifest = match self.client.stream_manifest(track.id, &track.quality).await {
765                Ok(m) => m,
766                Err(e) => {
767                    tracing::debug!("prefetch manifest failed for {}: {e}", track.id);
768                    continue;
769                }
770            };
771            let url = match &manifest {
772                MonoEvent::StreamManifest { url, .. } => url.clone(),
773                _ => continue,
774            };
775            let parsed = match url.parse::<reqwest::Url>() {
776                Ok(u) => u,
777                Err(_) => continue,
778            };
779
780            // Start download — the temp file will buffer audio in the background
781            let reader = match stream_download::StreamDownload::new_http(
782                parsed,
783                stream_download::storage::temp::TempStorageProvider::new(),
784                stream_download::Settings::default(),
785            )
786            .await
787            {
788                Ok(r) => r,
789                Err(e) => {
790                    tracing::debug!("prefetch download failed for {}: {e}", track.id);
791                    continue;
792                }
793            };
794
795            tracing::debug!("prefetched track {} ({})", track.id, track.title);
796            let mut inner = self.inner.lock().await;
797            inner.prefetched.insert(track.id, Box::new(reader));
798        }
799    }
800
801    /// Subscribe to now-playing updates
802    pub fn subscribe_now_playing(&self) -> watch::Receiver<NowPlaying> {
803        self.now_playing_rx.clone()
804    }
805
806    /// Snapshot current state for persistence
807    pub async fn get_state(&self) -> PlayerState {
808        let inner = self.inner.lock().await;
809        PlayerState {
810            current_track: inner.current_track.clone(),
811            position_secs: self.sink.get_pos().as_secs_f32(),
812            queue: inner.queue.iter().cloned().collect(),
813            history: inner.history.clone(),
814            volume: inner.volume,
815            preamp: inner.preamp,
816        }
817    }
818
819    /// Save current state to disk
820    pub async fn save_state(&self) {
821        let state = self.get_state().await;
822        state.save();
823    }
824
825    /// Restore state from disk — resumes playback at the saved position
826    pub async fn restore_state(&self) {
827        if let Some(state) = PlayerState::load() {
828            let resume_track = state.current_track.clone();
829            let resume_pos = state.position_secs;
830
831            {
832                let mut inner = self.inner.lock().await;
833                inner.queue = state.queue.into_iter().collect();
834                inner.history = state.history;
835                inner.volume = state.volume;
836                inner.preamp = state.preamp;
837                self.apply_volume(&inner);
838            }
839
840            // Resume the track that was playing, seeking to saved position
841            if let Some(track) = resume_track {
842                tracing::info!(
843                    "resuming '{}' at {:.0}s",
844                    track.title,
845                    resume_pos
846                );
847                match self.start_playback(track).await {
848                    Ok(()) => {
849                        // Start paused so it doesn't blast on startup
850                        self.sink.pause();
851                        let mut inner = self.inner.lock().await;
852                        inner.status = PlayStatus::Paused;
853                        drop(inner);
854                        self.broadcast_now_playing().await;
855                    }
856                    Err(e) => {
857                        tracing::error!("failed to resume track: {e}");
858                    }
859                }
860            } else {
861                self.broadcast_now_playing().await;
862            }
863
864            tracing::info!("restored player state from disk");
865        }
866    }
867}
868
869/// Build a QueuedTrack from track info (or fallback to minimal metadata)
870fn make_queued_track(id: u64, quality: &str, info: Option<MonoEvent>) -> QueuedTrack {
871    match info {
872        Some(MonoEvent::Track {
873            title,
874            artist,
875            album,
876            duration_secs,
877            cover_id,
878            ..
879        }) => QueuedTrack {
880            id,
881            title,
882            artist,
883            album,
884            duration_secs,
885            quality: quality.to_string(),
886            cover_id,
887        },
888        _ => QueuedTrack {
889            id,
890            title: format!("Track {id}"),
891            artist: String::new(),
892            album: String::new(),
893            duration_secs: 0,
894            quality: quality.to_string(),
895            cover_id: None,
896        },
897    }
898}