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 queue;
|
||||
pub mod reco_store;
|
||||
pub mod session_store;
|
||||
pub mod share;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -120,6 +121,9 @@ pub fn run() {
|
||||
// Initialize API cache state
|
||||
let api_cache_state = api_cache::ApiCacheState::new()
|
||||
.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()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
@@ -179,6 +183,7 @@ pub fn run() {
|
||||
.manage(lyrics_state)
|
||||
.manage(reco_state)
|
||||
.manage(api_cache_state)
|
||||
.manage(session_store_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Auth commands
|
||||
commands::init_client,
|
||||
@@ -347,6 +352,13 @@ pub fn run() {
|
||||
reco_store::commands::reco_get_home,
|
||||
reco_store::commands::reco_train_scores,
|
||||
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!())
|
||||
.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
|
||||
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
|
||||
import {
|
||||
subscribe as subscribeLyrics,
|
||||
@@ -783,7 +795,7 @@
|
||||
}
|
||||
|
||||
// Auth Handlers
|
||||
function handleLoginSuccess(info: UserInfo) {
|
||||
async function handleLoginSuccess(info: UserInfo) {
|
||||
setLoggedIn(info);
|
||||
showToast(`Welcome, ${info.userName}!`, 'success');
|
||||
|
||||
@@ -793,6 +805,64 @@
|
||||
}).catch(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() {
|
||||
@@ -806,6 +876,8 @@
|
||||
console.error('Failed to clear credentials:', clearErr);
|
||||
// Don't block logout if clearing fails
|
||||
}
|
||||
// Clear session state
|
||||
await clearSession();
|
||||
setLoggedOut();
|
||||
currentTrack = null;
|
||||
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
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!isLoggedIn) return;
|
||||
@@ -884,6 +1003,20 @@
|
||||
// Keyboard navigation
|
||||
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
|
||||
initDownloadStates();
|
||||
startDownloadEventListeners();
|
||||
@@ -977,8 +1110,12 @@
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Save session before cleanup
|
||||
saveSessionBeforeClose();
|
||||
cleanupBootstrap();
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
stopDownloadEventListeners();
|
||||
unsubscribeDownloads();
|
||||
unsubscribeToast();
|
||||
|
||||
Reference in new issue
Block a user