From 863b4017714130f042f993e7c9d0f3f8adee22bf Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 2 Nov 2026 13:12:48 +0100 Subject: state: persist the queue and pick up where playback stopped The queue, its shuffle order and whether playback was running are written to ~/.local/state/rsplayer/state.json whenever they change, and on shutdown, then restored at daemon start. Tracks that disappeared from disk are dropped instead of failing on play. `rsp resume`/`rsp toggle` now start the restored track when nothing is loaded into the engine yet, which is the state right after a restart with a queue that was paused. --- src/daemon.rs | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 6 deletions(-) (limited to 'src/daemon.rs') diff --git a/src/daemon.rs b/src/daemon.rs index 41c21c6..8f470c1 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -10,6 +10,7 @@ use crate::config::Config; use crate::notification; use crate::queue::{Queue, ShuffleMode, Track}; use crate::scrobbler::Scrobbler; +use crate::state; use reqwest::blocking::Client; use serde::Deserialize; use std::error::Error; @@ -231,7 +232,24 @@ pub fn start_daemon() -> Result<(), Box> { if let Some(vol) = config.volume { audio_engine.set_volume(vol as f32 / 100.0); } - if let Some(ref shuffle_str) = config.shuffle + + // Restore the previous session's queue. It beats the config default for + // shuffle because it is the newer choice, and it is what makes a restart + // (or a reboot) pick up where playback stopped. + let mut restored_queue = false; + let mut resume_track: Option = None; + if let Some(saved) = state::load_from(&state::state_path()) + && let Ok(mut q) = queue.lock() + && !saved.tracks.is_empty() + { + *q = Queue::from_snapshot(saved.snapshot()); + restored_queue = true; + if saved.playing { + resume_track = q.current().cloned(); + } + } + if !restored_queue + && let Some(ref shuffle_str) = config.shuffle && let Ok(mut q) = queue.lock() { match shuffle_str.as_str() { @@ -270,6 +288,16 @@ pub fn start_daemon() -> Result<(), Box> { } let scrobble_state = Arc::new(Mutex::new(ScrobbleState::new())); + // Pick the restored track back up. No notification: this happens on daemon + // start (usually at login), nobody asked for a popup there. + if let Some(track) = resume_track { + audio_engine.play(track.path.clone()); + spawn_send_now_playing(&scrobbler, &track); + if let Ok(mut s) = scrobble_state.lock() { + s.start(track); + } + } + // Save full config config.save()?; @@ -308,6 +336,7 @@ pub fn start_daemon() -> Result<(), Box> { } false } + let mut last_state: Option<(usize, usize, ShuffleMode, bool)> = None; loop { // Check for shutdown signal if shutdown_flag.load(Ordering::SeqCst) { @@ -356,6 +385,26 @@ pub fn start_daemon() -> Result<(), Box> { } } + // Keep the saved state in step with the queue so a restart resumes + // here. The file is small and only rewritten when something moved. + let pending_save = if let Ok(q) = queue.lock() { + let playing = audio_engine.is_playing(); + let fingerprint = (q.len(), q.position(), *q.shuffle_mode(), playing); + if last_state == Some(fingerprint) { + None + } else { + last_state = Some(fingerprint); + Some(state::SavedState::new(q.snapshot(), playing)) + } + } else { + None + }; + if let Some(saved) = pending_save + && let Err(e) = state::save_to(&state::state_path(), &saved) + { + eprintln!("Failed to save queue state: {}", e); + } + match listener.accept() { Ok((stream, _)) => { let audio_clone = Arc::clone(&audio_engine); @@ -384,6 +433,14 @@ pub fn start_daemon() -> Result<(), Box> { thread::sleep(Duration::from_millis(50)); } + // Final write so a shutdown while paused keeps the current queue. + if let Ok(q) = queue.lock() { + let saved = state::SavedState::new(q.snapshot(), audio_engine.is_playing()); + if let Err(e) = state::save_to(&state::state_path(), &saved) { + eprintln!("Failed to save queue state: {}", e); + } + } + // Cleanup cleanup_daemon(&socket_path, &pid_path); println!("Daemon stopped"); @@ -871,6 +928,32 @@ fn stream_synced_lyrics( } } +/// Unpauses the stream, or starts the queue's current track when nothing is +/// loaded in the engine yet — which is the state right after a daemon start +/// with a restored queue, where a plain `resume` would otherwise do nothing. +/// +/// Returns whether there was anything to resume. +fn resume_queue_track( + audio: &AudioEngine, + queue: &Arc>, + scrobbler: &Arc>, + scrobble_state: &Arc>, +) -> bool { + if audio.current_track().is_some() { + audio.resume(); + return true; + } + let Some(track) = queue.lock().ok().and_then(|q| q.current().cloned()) else { + return false; + }; + audio.play(track.path.clone()); + spawn_send_now_playing(scrobbler, &track); + if let Ok(mut s) = scrobble_state.lock() { + s.start(track); + } + true +} + fn process_command( cmd: &str, audio: &AudioEngine, @@ -903,19 +986,23 @@ fn process_command( "Paused\n".to_string() } "resume" => { - audio.resume(); - notification::notify_change("Resumed"); - "Resumed\n".to_string() + if resume_queue_track(audio, queue, scrobbler, scrobble_state) { + notification::notify_change("Resumed"); + "Resumed\n".to_string() + } else { + "Nothing to resume\n".to_string() + } } "toggle" => { if audio.is_playing() { audio.pause(); notification::notify_change("Paused"); "Paused\n".to_string() - } else { - audio.resume(); + } else if resume_queue_track(audio, queue, scrobbler, scrobble_state) { notification::notify_change("Resumed"); "Resumed\n".to_string() + } else { + "Nothing to resume\n".to_string() } } "stop" => { -- cgit v1.2.3