From 7547fed56dbb0272fe033bc721aefcba53f66752 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:03:20 -0500 Subject: [PATCH] playback: remember the last transport so a reload lands where you left off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlayerState carries last_transport, mirrored as events go past, and player_state reports it. The frontend restores everything else from localStorage, but the transport cannot come from there — the audio lives in Rust, and a reload that trusted the browser's copy would show a position the engine had never heard of. Co-Authored-By: Claude Opus 5 --- src-tauri/src/playback.rs | 56 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src-tauri/src/playback.rs b/src-tauri/src/playback.rs index a39014a..df2fb39 100644 --- a/src-tauri/src/playback.rs +++ b/src-tauri/src/playback.rs @@ -141,6 +141,28 @@ pub struct PlayerState { /// Transfers whose completed cache file should also be organized into the /// library. A save can subscribe to a stream/preload already in progress. pub deferred_saves: Mutex>, + /// The last transport state forwarded to the webview. + /// + /// The audio thread is send-only — it reports by event and answers no + /// questions — so a webview that RELOADS has no way to learn that audio is + /// still running. It kept receiving position ticks and drew a moving + /// scrubber under the words "Nothing playing", because every other part of + /// the player store had been wiped with the page. This is the answer to + /// "what is the audio thread doing right now", mirrored here as the events + /// go past and read back by `player_state` at boot. + pub last_transport: Mutex, +} + +/// What the audio thread is doing, as of its last event. Deliberately only +/// TRANSPORT: which song this is, and the queue around it, are the frontend's +/// own knowledge and are restored from its session snapshot. +#[derive(Serialize, Clone, Default)] +pub struct PlaybackSnapshot { + /// "idle" | "buffering" | "playing" | "paused" — the same vocabulary the + /// frontend's `player.status` uses, so a restore is a direct assignment. + pub status: String, + pub position: f64, + pub duration: Option, } #[derive(Serialize, Clone)] @@ -168,6 +190,10 @@ pub fn init(app: &tauri::AppHandle) { waveform_jobs: Mutex::new(HashMap::new()), preloads: Mutex::new(HashMap::new()), deferred_saves: Mutex::new(HashMap::new()), + last_transport: Mutex::new(PlaybackSnapshot { + status: "idle".into(), + ..Default::default() + }), }); let handle = app.clone(); @@ -201,6 +227,36 @@ pub fn init(app: &tauri::AppHandle) { } PlayerEvent::Error(message) => PlayerUiEvent::Error { message }, }; + // Mirror the transport before emitting, so a webview that + // reloads mid-track can ask what it missed. TrackEnded is + // deliberately not recorded: the frontend owns the queue and + // decides what follows, and the next Buffering/Stopped says + // what actually happened. + if let Some(state) = handle.try_state::() { + if let Ok(mut last) = state.last_transport.lock() { + match &ui { + PlayerUiEvent::Buffering => { + last.status = "buffering".into(); + last.position = 0.0; + } + PlayerUiEvent::Playing => last.status = "playing".into(), + PlayerUiEvent::Paused => last.status = "paused".into(), + PlayerUiEvent::Stopped | PlayerUiEvent::Error { .. } => { + *last = PlaybackSnapshot { + status: "idle".into(), + ..Default::default() + }; + } + PlayerUiEvent::Position { secs, duration } => { + last.position = *secs; + if duration.is_some() { + last.duration = *duration; + } + } + PlayerUiEvent::TrackEnded { .. } => {} + } + } + } let _ = handle.emit("soulseek:player", ui); } })