Implement session persistence between app restarts

Backend (Rust):
- Create session_store module with SQLite database
- Store queue tracks, current index, position, volume, shuffle/repeat
- Add Tauri commands: save_session_state, load_session_state, etc.
- Use WAL mode for better concurrent access

Frontend (TypeScript):
- Create sessionService.ts with save/load functions
- Restore session after login (queue, track info, volume, modes)
- Save session on beforeunload and visibility change
- Clear session on logout
- Debounced position saving during playback

Users can now close the app and resume where they left off.
This commit is contained in:
vicrodh committed 2026-01-11 03:37:49 -06:00
1 parent e9e82329bd
commit ddc9497286
4 files changed
+681 -1

No files matched your search

+12
View File
@@ -19,6 +19,7 @@ pub mod media_controls;
pub mod player; pub mod player;
pub mod queue; pub mod queue;
pub mod reco_store; pub mod reco_store;
pub mod session_store;
pub mod share; pub mod share;
use std::sync::Arc; use std::sync::Arc;
@@ -120,6 +121,9 @@ pub fn run() {
// Initialize API cache state // Initialize API cache state
let api_cache_state = api_cache::ApiCacheState::new() let api_cache_state = api_cache::ApiCacheState::new()
.expect("Failed to initialize API cache"); .expect("Failed to initialize API cache");
// Initialize session store state
let session_store_state = session_store::SessionStoreState::new()
.expect("Failed to initialize session store");
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
@@ -179,6 +183,7 @@ pub fn run() {
.manage(lyrics_state) .manage(lyrics_state)
.manage(reco_state) .manage(reco_state)
.manage(api_cache_state) .manage(api_cache_state)
.manage(session_store_state)
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
// Auth commands // Auth commands
commands::init_client, commands::init_client,
@@ -347,6 +352,13 @@ pub fn run() {
reco_store::commands::reco_get_home, reco_store::commands::reco_get_home,
reco_store::commands::reco_train_scores, reco_store::commands::reco_train_scores,
reco_store::commands::reco_get_home_ml, reco_store::commands::reco_get_home_ml,
// Session persistence commands
session_store::save_session_state,
session_store::load_session_state,
session_store::save_session_volume,
session_store::save_session_position,
session_store::save_session_playback_mode,
session_store::clear_session,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+357
View File
@@ -0,0 +1,357 @@
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
/// Represents a track in the persisted queue
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedQueueTrack {
pub id: u64,
pub title: String,
pub artist: String,
pub album: String,
pub duration_secs: u64,
pub artwork_url: Option<String>,
}
/// Represents the full persisted session state
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedSession {
pub queue_tracks: Vec<PersistedQueueTrack>,
pub current_index: Option<usize>,
pub current_position_secs: u64,
pub volume: f32,
pub shuffle_enabled: bool,
pub repeat_mode: String, // "off", "all", "one"
pub was_playing: bool,
pub saved_at: i64,
}
impl Default for PersistedSession {
fn default() -> Self {
Self {
queue_tracks: Vec::new(),
current_index: None,
current_position_secs: 0,
volume: 0.75,
shuffle_enabled: false,
repeat_mode: "off".to_string(),
was_playing: false,
saved_at: 0,
}
}
}
pub struct SessionStore {
conn: Connection,
}
impl SessionStore {
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz-nix");
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = data_dir.join("session.db");
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open session database: {}", e))?;
// Enable WAL mode for better concurrent access
conn.execute_batch("PRAGMA journal_mode=WAL;")
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
// Create tables
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS player_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
current_index INTEGER,
current_position_secs INTEGER NOT NULL DEFAULT 0,
volume REAL NOT NULL DEFAULT 0.75,
shuffle_enabled INTEGER NOT NULL DEFAULT 0,
repeat_mode TEXT NOT NULL DEFAULT 'off',
was_playing INTEGER NOT NULL DEFAULT 0,
saved_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS queue_tracks (
position INTEGER PRIMARY KEY,
track_id INTEGER NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT NOT NULL,
duration_secs INTEGER NOT NULL,
artwork_url TEXT
);
-- Insert default row if not exists
INSERT OR IGNORE INTO player_state (id, current_position_secs, volume, shuffle_enabled, repeat_mode, was_playing, saved_at)
VALUES (1, 0, 0.75, 0, 'off', 0, 0);
"
).map_err(|e| format!("Failed to create session tables: {}", e))?;
Ok(Self { conn })
}
/// Save the complete session state
pub fn save_session(&self, session: &PersistedSession) -> Result<(), String> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
// Use a transaction for atomicity
self.conn.execute("BEGIN TRANSACTION", [])
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
// Clear existing queue
if let Err(e) = self.conn.execute("DELETE FROM queue_tracks", []) {
let _ = self.conn.execute("ROLLBACK", []);
return Err(format!("Failed to clear queue: {}", e));
}
// Insert queue tracks
for (pos, track) in session.queue_tracks.iter().enumerate() {
if let Err(e) = self.conn.execute(
"INSERT INTO queue_tracks (position, track_id, title, artist, album, duration_secs, artwork_url)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
pos as i64,
track.id as i64,
track.title,
track.artist,
track.album,
track.duration_secs as i64,
track.artwork_url,
],
) {
let _ = self.conn.execute("ROLLBACK", []);
return Err(format!("Failed to insert queue track: {}", e));
}
}
// Update player state
if let Err(e) = self.conn.execute(
"UPDATE player_state SET
current_index = ?1,
current_position_secs = ?2,
volume = ?3,
shuffle_enabled = ?4,
repeat_mode = ?5,
was_playing = ?6,
saved_at = ?7
WHERE id = 1",
params![
session.current_index.map(|i| i as i64),
session.current_position_secs as i64,
session.volume as f64,
session.shuffle_enabled as i64,
session.repeat_mode,
session.was_playing as i64,
now,
],
) {
let _ = self.conn.execute("ROLLBACK", []);
return Err(format!("Failed to update player state: {}", e));
}
self.conn.execute("COMMIT", [])
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
Ok(())
}
/// Load the persisted session state
pub fn load_session(&self) -> Result<PersistedSession, String> {
// Load player state
let (current_index, current_position_secs, volume, shuffle_enabled, repeat_mode, was_playing, saved_at):
(Option<i64>, i64, f64, i64, String, i64, i64) = self.conn
.query_row(
"SELECT current_index, current_position_secs, volume, shuffle_enabled, repeat_mode, was_playing, saved_at
FROM player_state WHERE id = 1",
[],
|row| Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
)),
)
.map_err(|e| format!("Failed to load player state: {}", e))?;
// Load queue tracks
let mut stmt = self.conn
.prepare("SELECT track_id, title, artist, album, duration_secs, artwork_url FROM queue_tracks ORDER BY position")
.map_err(|e| format!("Failed to prepare queue query: {}", e))?;
let tracks: Vec<PersistedQueueTrack> = stmt
.query_map([], |row| {
Ok(PersistedQueueTrack {
id: row.get::<_, i64>(0)? as u64,
title: row.get(1)?,
artist: row.get(2)?,
album: row.get(3)?,
duration_secs: row.get::<_, i64>(4)? as u64,
artwork_url: row.get(5)?,
})
})
.map_err(|e| format!("Failed to query queue tracks: {}", e))?
.filter_map(|r| r.ok())
.collect();
Ok(PersistedSession {
queue_tracks: tracks,
current_index: current_index.map(|i| i as usize),
current_position_secs: current_position_secs as u64,
volume: volume as f32,
shuffle_enabled: shuffle_enabled != 0,
repeat_mode,
was_playing: was_playing != 0,
saved_at,
})
}
/// Quick save of just player position (for debounced saves during playback)
pub fn save_position(&self, position_secs: u64) -> Result<(), String> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
self.conn
.execute(
"UPDATE player_state SET current_position_secs = ?1, saved_at = ?2 WHERE id = 1",
params![position_secs as i64, now],
)
.map_err(|e| format!("Failed to save position: {}", e))?;
Ok(())
}
/// Quick save of volume
pub fn save_volume(&self, volume: f32) -> Result<(), String> {
self.conn
.execute(
"UPDATE player_state SET volume = ?1 WHERE id = 1",
params![volume as f64],
)
.map_err(|e| format!("Failed to save volume: {}", e))?;
Ok(())
}
/// Save shuffle/repeat state
pub fn save_playback_mode(&self, shuffle: bool, repeat_mode: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE player_state SET shuffle_enabled = ?1, repeat_mode = ?2 WHERE id = 1",
params![shuffle as i64, repeat_mode],
)
.map_err(|e| format!("Failed to save playback mode: {}", e))?;
Ok(())
}
/// Clear the session (e.g., on logout)
pub fn clear_session(&self) -> Result<(), String> {
self.conn.execute("DELETE FROM queue_tracks", [])
.map_err(|e| format!("Failed to clear queue: {}", e))?;
self.conn.execute(
"UPDATE player_state SET current_index = NULL, current_position_secs = 0, was_playing = 0 WHERE id = 1",
[],
).map_err(|e| format!("Failed to reset player state: {}", e))?;
Ok(())
}
}
/// Thread-safe wrapper for SessionStore
pub struct SessionStoreState {
pub store: Arc<Mutex<SessionStore>>,
}
impl SessionStoreState {
pub fn new() -> Result<Self, String> {
Ok(Self {
store: Arc::new(Mutex::new(SessionStore::new()?)),
})
}
}
// Tauri commands
#[tauri::command]
pub fn save_session_state(
state: tauri::State<'_, SessionStoreState>,
queue_tracks: Vec<PersistedQueueTrack>,
current_index: Option<usize>,
current_position_secs: u64,
volume: f32,
shuffle_enabled: bool,
repeat_mode: String,
was_playing: bool,
) -> Result<(), String> {
let session = PersistedSession {
queue_tracks,
current_index,
current_position_secs,
volume,
shuffle_enabled,
repeat_mode,
was_playing,
saved_at: 0, // Will be set in save_session
};
let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
store.save_session(&session)
}
#[tauri::command]
pub fn load_session_state(
state: tauri::State<'_, SessionStoreState>,
) -> Result<PersistedSession, String> {
let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
store.load_session()
}
#[tauri::command]
pub fn save_session_volume(
state: tauri::State<'_, SessionStoreState>,
volume: f32,
) -> Result<(), String> {
let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
store.save_volume(volume)
}
#[tauri::command]
pub fn save_session_position(
state: tauri::State<'_, SessionStoreState>,
position_secs: u64,
) -> Result<(), String> {
let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
store.save_position(position_secs)
}
#[tauri::command]
pub fn save_session_playback_mode(
state: tauri::State<'_, SessionStoreState>,
shuffle: bool,
repeat_mode: String,
) -> Result<(), String> {
let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
store.save_playback_mode(shuffle, &repeat_mode)
}
#[tauri::command]
pub fn clear_session(
state: tauri::State<'_, SessionStoreState>,
) -> Result<(), String> {
let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
store.clear_session()
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Session Persistence Service
*
* Handles saving and restoring playback state between app sessions.
* Saves: queue, current track, position, volume, shuffle/repeat modes
*/
import { invoke } from '@tauri-apps/api/core';
export interface PersistedQueueTrack {
id: number;
title: string;
artist: string;
album: string;
duration_secs: number;
artwork_url: string | null;
}
export interface PersistedSession {
queue_tracks: PersistedQueueTrack[];
current_index: number | null;
current_position_secs: number;
volume: number;
shuffle_enabled: boolean;
repeat_mode: string; // "off" | "all" | "one"
was_playing: boolean;
saved_at: number;
}
/**
* Save the complete session state
*/
export async function saveSessionState(
queueTracks: PersistedQueueTrack[],
currentIndex: number | null,
currentPositionSecs: number,
volume: number,
shuffleEnabled: boolean,
repeatMode: string,
wasPlaying: boolean
): Promise<void> {
try {
await invoke('save_session_state', {
queueTracks,
currentIndex,
currentPositionSecs,
volume,
shuffleEnabled,
repeatMode,
wasPlaying,
});
console.log('[Session] State saved successfully');
} catch (err) {
console.error('[Session] Failed to save state:', err);
}
}
/**
* Load the persisted session state
*/
export async function loadSessionState(): Promise<PersistedSession | null> {
try {
const session = await invoke<PersistedSession>('load_session_state');
console.log('[Session] State loaded:', {
tracks: session.queue_tracks.length,
currentIndex: session.current_index,
position: session.current_position_secs,
volume: session.volume,
});
return session;
} catch (err) {
console.error('[Session] Failed to load state:', err);
return null;
}
}
/**
* Quick save of just the playback position (debounced during playback)
*/
export async function saveSessionPosition(positionSecs: number): Promise<void> {
try {
await invoke('save_session_position', { positionSecs });
} catch (err) {
console.error('[Session] Failed to save position:', err);
}
}
/**
* Quick save of volume
*/
export async function saveSessionVolume(volume: number): Promise<void> {
try {
await invoke('save_session_volume', { volume });
} catch (err) {
console.error('[Session] Failed to save volume:', err);
}
}
/**
* Save shuffle and repeat mode
*/
export async function saveSessionPlaybackMode(
shuffle: boolean,
repeatMode: string
): Promise<void> {
try {
await invoke('save_session_playback_mode', { shuffle, repeatMode });
} catch (err) {
console.error('[Session] Failed to save playback mode:', err);
}
}
/**
* Clear the session (e.g., on logout)
*/
export async function clearSession(): Promise<void> {
try {
await invoke('clear_session');
console.log('[Session] Session cleared');
} catch (err) {
console.error('[Session] Failed to clear session:', err);
}
}
// Debounce helper for position saves
let positionSaveTimeout: ReturnType<typeof setTimeout> | null = null;
const POSITION_SAVE_DEBOUNCE_MS = 5000; // Save position every 5 seconds max
/**
* Debounced position save - call frequently, saves every 5 seconds
*/
export function debouncedSavePosition(positionSecs: number): void {
if (positionSaveTimeout) {
clearTimeout(positionSaveTimeout);
}
positionSaveTimeout = setTimeout(() => {
saveSessionPosition(positionSecs);
positionSaveTimeout = null;
}, POSITION_SAVE_DEBOUNCE_MS);
}
/**
* Force save position immediately (e.g., on pause or app close)
*/
export function flushPositionSave(positionSecs: number): void {
if (positionSaveTimeout) {
clearTimeout(positionSaveTimeout);
positionSaveTimeout = null;
}
saveSessionPosition(positionSecs);
}
/**
* Build a complete session state from current app state and save it
*/
export async function saveCurrentSession(
getQueueState: () => { tracks: Array<{ id: number; title: string; artist: string; album: string; duration_secs: number; artwork_url: string | null }>; currentIndex: number | null },
getPlayerState: () => { currentTime: number; volume: number; isPlaying: boolean },
getPlaybackMode: () => { shuffle: boolean; repeat: string }
): Promise<void> {
const queueState = getQueueState();
const playerState = getPlayerState();
const playbackMode = getPlaybackMode();
await saveSessionState(
queueState.tracks,
queueState.currentIndex,
Math.floor(playerState.currentTime),
playerState.volume / 100, // Convert 0-100 to 0-1
playbackMode.shuffle,
playbackMode.repeat,
playerState.isPlaying
);
}
+138 -1
View File
@@ -170,6 +170,18 @@
// Recommendation scoring // Recommendation scoring
import { trainScores } from '$lib/services/recoService'; import { trainScores } from '$lib/services/recoService';
// Session persistence
import {
loadSessionState,
saveSessionState,
saveSessionVolume,
saveSessionPlaybackMode,
debouncedSavePosition,
flushPositionSave,
clearSession,
type PersistedQueueTrack
} from '$lib/services/sessionService';
// Lyrics state management // Lyrics state management
import { import {
subscribe as subscribeLyrics, subscribe as subscribeLyrics,
@@ -783,7 +795,7 @@
} }
// Auth Handlers // Auth Handlers
function handleLoginSuccess(info: UserInfo) { async function handleLoginSuccess(info: UserInfo) {
setLoggedIn(info); setLoggedIn(info);
showToast(`Welcome, ${info.userName}!`, 'success'); showToast(`Welcome, ${info.userName}!`, 'success');
@@ -793,6 +805,64 @@
}).catch(err => { }).catch(err => {
console.debug('[Reco] Score training failed:', err); console.debug('[Reco] Score training failed:', err);
}); });
// Restore previous session if available
try {
const session = await loadSessionState();
if (session && session.queue_tracks.length > 0) {
console.log('[Session] Restoring previous session...');
// Restore queue
const tracks: BackendQueueTrack[] = session.queue_tracks.map(t => ({
id: t.id,
title: t.title,
artist: t.artist,
album: t.album,
duration_secs: t.duration_secs,
artwork_url: t.artwork_url
}));
await setQueue(tracks, session.current_index ?? 0, true);
// Restore shuffle/repeat mode
if (session.shuffle_enabled) {
await invoke('set_shuffle', { enabled: true });
}
if (session.repeat_mode !== 'off') {
await invoke('set_repeat', { mode: session.repeat_mode });
}
// Restore volume
playerSetVolume(Math.round(session.volume * 100));
// If there was a track playing, restore it (paused)
if (session.current_index !== null && tracks[session.current_index]) {
const track = tracks[session.current_index];
showToast(`Restored: ${track.title}`, 'info');
// Set the current track info without auto-playing
setCurrentTrack({
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
artwork: track.artwork_url || '',
duration: track.duration_secs,
quality: 'Restored',
});
// Seek to saved position if > 0
if (session.current_position_secs > 0) {
// Note: actual seek will happen when user presses play
console.log(`[Session] Will resume at ${session.current_position_secs}s`);
}
}
console.log('[Session] Session restored successfully');
}
} catch (err) {
console.error('[Session] Failed to restore session:', err);
}
} }
async function handleLogout() { async function handleLogout() {
@@ -806,6 +876,8 @@
console.error('Failed to clear credentials:', clearErr); console.error('Failed to clear credentials:', clearErr);
// Don't block logout if clearing fails // Don't block logout if clearing fails
} }
// Clear session state
await clearSession();
setLoggedOut(); setLoggedOut();
currentTrack = null; currentTrack = null;
isPlaying = false; isPlaying = false;
@@ -816,6 +888,53 @@
} }
} }
// Save session state before window closes
async function saveSessionBeforeClose() {
if (!isLoggedIn || !currentTrack) return;
try {
// Get current queue state from backend
const queueState = await getBackendQueueState();
if (!queueState) return;
// Build persisted queue tracks
const allTracks: PersistedQueueTrack[] = [];
if (queueState.current_track) {
allTracks.push({
id: queueState.current_track.id,
title: queueState.current_track.title,
artist: queueState.current_track.artist,
album: queueState.current_track.album,
duration_secs: queueState.current_track.duration_secs,
artwork_url: queueState.current_track.artwork_url
});
}
for (const track of queueState.upcoming) {
allTracks.push({
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
duration_secs: track.duration_secs,
artwork_url: track.artwork_url
});
}
await saveSessionState(
allTracks,
queueState.current_index,
Math.floor(currentTime),
volume / 100,
isShuffle,
repeatMode,
isPlaying
);
console.log('[Session] Session saved on close');
} catch (err) {
console.error('[Session] Failed to save session on close:', err);
}
}
// Keyboard Shortcuts // Keyboard Shortcuts
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (!isLoggedIn) return; if (!isLoggedIn) return;
@@ -884,6 +1003,20 @@
// Keyboard navigation // Keyboard navigation
document.addEventListener('keydown', handleKeydown); document.addEventListener('keydown', handleKeydown);
// Session save on window close/hide
const handleBeforeUnload = () => {
saveSessionBeforeClose();
};
window.addEventListener('beforeunload', handleBeforeUnload);
// Also save when visibility changes (app goes to background)
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
saveSessionBeforeClose();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// Initialize download states // Initialize download states
initDownloadStates(); initDownloadStates();
startDownloadEventListeners(); startDownloadEventListeners();
@@ -977,8 +1110,12 @@
}); });
return () => { return () => {
// Save session before cleanup
saveSessionBeforeClose();
cleanupBootstrap(); cleanupBootstrap();
document.removeEventListener('keydown', handleKeydown); document.removeEventListener('keydown', handleKeydown);
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
stopDownloadEventListeners(); stopDownloadEventListeners();
unsubscribeDownloads(); unsubscribeDownloads();
unsubscribeToast(); unsubscribeToast();