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:
1 parent
e9e82329bd
commit
ddc9497286
4 files changed
+681
-1
No files matched your search
@@ -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");
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user