aboutsummaryrefslogtreecommitdiffstats
path: root/src/daemon.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-02 13:12:48 +0100
committersalmonik <salmonik@tuta.io>2026-11-02 13:12:48 +0100
commit863b4017714130f042f993e7c9d0f3f8adee22bf (patch)
treee5ae8094f6be4f0c0a5332a9ca4bb95c8bf87b18 /src/daemon.rs
parentqueue: use let chains and tidy imports (diff)
downloadrsplayer-863b4017714130f042f993e7c9d0f3f8adee22bf.tar.gz
rsplayer-863b4017714130f042f993e7c9d0f3f8adee22bf.zip
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.
Diffstat (limited to 'src/daemon.rs')
-rw-r--r--src/daemon.rs99
1 files changed, 93 insertions, 6 deletions
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;
10use crate::notification; 10use crate::notification;
11use crate::queue::{Queue, ShuffleMode, Track}; 11use crate::queue::{Queue, ShuffleMode, Track};
12use crate::scrobbler::Scrobbler; 12use crate::scrobbler::Scrobbler;
13use crate::state;
13use reqwest::blocking::Client; 14use reqwest::blocking::Client;
14use serde::Deserialize; 15use serde::Deserialize;
15use std::error::Error; 16use std::error::Error;
@@ -231,7 +232,24 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
231 if let Some(vol) = config.volume { 232 if let Some(vol) = config.volume {
232 audio_engine.set_volume(vol as f32 / 100.0); 233 audio_engine.set_volume(vol as f32 / 100.0);
233 } 234 }
234 if let Some(ref shuffle_str) = config.shuffle 235
236 // Restore the previous session's queue. It beats the config default for
237 // shuffle because it is the newer choice, and it is what makes a restart
238 // (or a reboot) pick up where playback stopped.
239 let mut restored_queue = false;
240 let mut resume_track: Option<Track> = None;
241 if let Some(saved) = state::load_from(&state::state_path())
242 && let Ok(mut q) = queue.lock()
243 && !saved.tracks.is_empty()
244 {
245 *q = Queue::from_snapshot(saved.snapshot());
246 restored_queue = true;
247 if saved.playing {
248 resume_track = q.current().cloned();
249 }
250 }
251 if !restored_queue
252 && let Some(ref shuffle_str) = config.shuffle
235 && let Ok(mut q) = queue.lock() 253 && let Ok(mut q) = queue.lock()
236 { 254 {
237 match shuffle_str.as_str() { 255 match shuffle_str.as_str() {
@@ -270,6 +288,16 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
270 } 288 }
271 let scrobble_state = Arc::new(Mutex::new(ScrobbleState::new())); 289 let scrobble_state = Arc::new(Mutex::new(ScrobbleState::new()));
272 290
291 // Pick the restored track back up. No notification: this happens on daemon
292 // start (usually at login), nobody asked for a popup there.
293 if let Some(track) = resume_track {
294 audio_engine.play(track.path.clone());
295 spawn_send_now_playing(&scrobbler, &track);
296 if let Ok(mut s) = scrobble_state.lock() {
297 s.start(track);
298 }
299 }
300
273 // Save full config 301 // Save full config
274 config.save()?; 302 config.save()?;
275 303
@@ -308,6 +336,7 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
308 } 336 }
309 false 337 false
310 } 338 }
339 let mut last_state: Option<(usize, usize, ShuffleMode, bool)> = None;
311 loop { 340 loop {
312 // Check for shutdown signal 341 // Check for shutdown signal
313 if shutdown_flag.load(Ordering::SeqCst) { 342 if shutdown_flag.load(Ordering::SeqCst) {
@@ -356,6 +385,26 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
356 } 385 }
357 } 386 }
358 387
388 // Keep the saved state in step with the queue so a restart resumes
389 // here. The file is small and only rewritten when something moved.
390 let pending_save = if let Ok(q) = queue.lock() {
391 let playing = audio_engine.is_playing();
392 let fingerprint = (q.len(), q.position(), *q.shuffle_mode(), playing);
393 if last_state == Some(fingerprint) {
394 None
395 } else {
396 last_state = Some(fingerprint);
397 Some(state::SavedState::new(q.snapshot(), playing))
398 }
399 } else {
400 None
401 };
402 if let Some(saved) = pending_save
403 && let Err(e) = state::save_to(&state::state_path(), &saved)
404 {
405 eprintln!("Failed to save queue state: {}", e);
406 }
407
359 match listener.accept() { 408 match listener.accept() {
360 Ok((stream, _)) => { 409 Ok((stream, _)) => {
361 let audio_clone = Arc::clone(&audio_engine); 410 let audio_clone = Arc::clone(&audio_engine);
@@ -384,6 +433,14 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
384 thread::sleep(Duration::from_millis(50)); 433 thread::sleep(Duration::from_millis(50));
385 } 434 }
386 435
436 // Final write so a shutdown while paused keeps the current queue.
437 if let Ok(q) = queue.lock() {
438 let saved = state::SavedState::new(q.snapshot(), audio_engine.is_playing());
439 if let Err(e) = state::save_to(&state::state_path(), &saved) {
440 eprintln!("Failed to save queue state: {}", e);
441 }
442 }
443
387 // Cleanup 444 // Cleanup
388 cleanup_daemon(&socket_path, &pid_path); 445 cleanup_daemon(&socket_path, &pid_path);
389 println!("Daemon stopped"); 446 println!("Daemon stopped");
@@ -871,6 +928,32 @@ fn stream_synced_lyrics(
871 } 928 }
872} 929}
873 930
931/// Unpauses the stream, or starts the queue's current track when nothing is
932/// loaded in the engine yet — which is the state right after a daemon start
933/// with a restored queue, where a plain `resume` would otherwise do nothing.
934///
935/// Returns whether there was anything to resume.
936fn resume_queue_track(
937 audio: &AudioEngine,
938 queue: &Arc<Mutex<Queue>>,
939 scrobbler: &Arc<RwLock<Scrobbler>>,
940 scrobble_state: &Arc<Mutex<ScrobbleState>>,
941) -> bool {
942 if audio.current_track().is_some() {
943 audio.resume();
944 return true;
945 }
946 let Some(track) = queue.lock().ok().and_then(|q| q.current().cloned()) else {
947 return false;
948 };
949 audio.play(track.path.clone());
950 spawn_send_now_playing(scrobbler, &track);
951 if let Ok(mut s) = scrobble_state.lock() {
952 s.start(track);
953 }
954 true
955}
956
874fn process_command( 957fn process_command(
875 cmd: &str, 958 cmd: &str,
876 audio: &AudioEngine, 959 audio: &AudioEngine,
@@ -903,19 +986,23 @@ fn process_command(
903 "Paused\n".to_string() 986 "Paused\n".to_string()
904 } 987 }
905 "resume" => { 988 "resume" => {
906 audio.resume(); 989 if resume_queue_track(audio, queue, scrobbler, scrobble_state) {
907 notification::notify_change("Resumed"); 990 notification::notify_change("Resumed");
908 "Resumed\n".to_string() 991 "Resumed\n".to_string()
992 } else {
993 "Nothing to resume\n".to_string()
994 }
909 } 995 }
910 "toggle" => { 996 "toggle" => {
911 if audio.is_playing() { 997 if audio.is_playing() {
912 audio.pause(); 998 audio.pause();
913 notification::notify_change("Paused"); 999 notification::notify_change("Paused");
914 "Paused\n".to_string() 1000 "Paused\n".to_string()
915 } else { 1001 } else if resume_queue_track(audio, queue, scrobbler, scrobble_state) {
916 audio.resume();
917 notification::notify_change("Resumed"); 1002 notification::notify_change("Resumed");
918 "Resumed\n".to_string() 1003 "Resumed\n".to_string()
1004 } else {
1005 "Nothing to resume\n".to_string()
919 } 1006 }
920 } 1007 }
921 "stop" => { 1008 "stop" => {