playback: remember the last transport so a reload lands where you left off

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 <noreply@anthropic.com>
This commit is contained in:
KeelrLeekandClaude Opus 5 committed 2026-08-03 07:03:20 -05:00
1 parent dc918c98ef
commit 7547fed56d
1 file changed
+56
+56
View File
@@ -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<HashMap<u64, PathBuf>>,
/// 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<PlaybackSnapshot>,
}
/// 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<f64>,
}
#[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::<PlayerState>() {
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);
}
})