diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 693a634c7..3690535c4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,7 @@ pub mod lastfm; pub mod library; pub mod lyrics; pub mod media_controls; +pub mod offline; pub mod player; pub mod playlist_import; pub mod queue; @@ -142,6 +143,9 @@ pub fn run() { .expect("Failed to initialize download settings"); // Initialize API keys state (for user-provided credentials) let api_keys_state = api_keys::create_api_keys_state(); + // Initialize offline mode state + let offline_state = offline::OfflineState::new() + .expect("Failed to initialize offline state"); // Read saved audio device setting for player initialization let saved_device = audio_settings_state @@ -253,6 +257,7 @@ pub fn run() { .manage(audio_settings_state) .manage(download_settings_state) .manage(api_keys_state) + .manage(offline_state) .invoke_handler(tauri::generate_handler![ // Auth commands commands::init_client, @@ -496,6 +501,11 @@ pub fn run() { api_keys::clear_discogs_credentials, api_keys::has_discogs_user_credentials, api_keys::get_embedded_credentials_status, + // Offline mode commands + offline::commands::get_offline_status, + offline::commands::get_offline_settings, + offline::commands::set_manual_offline, + offline::commands::set_show_partial_playlists, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/offline/mod.rs b/src-tauri/src/offline/mod.rs new file mode 100644 index 000000000..fae4a43f8 --- /dev/null +++ b/src-tauri/src/offline/mod.rs @@ -0,0 +1,237 @@ +//! Offline mode detection and settings +//! +//! Handles: +//! - Network connectivity detection +//! - Login state checking +//! - Manual offline mode toggle +//! - Offline settings persistence + +use rusqlite::{Connection, params}; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tauri::{AppHandle, Emitter}; + +/// Reason why the app is in offline mode +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum OfflineReason { + NoNetwork, + NotLoggedIn, + ManualOverride, +} + +/// Current offline status +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OfflineStatus { + pub is_offline: bool, + pub reason: Option, + pub manual_mode_enabled: bool, +} + +/// Persistent offline settings +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct OfflineSettings { + pub manual_offline_mode: bool, + pub show_partial_playlists: bool, +} + +/// SQLite-backed storage for offline settings +pub struct OfflineStore { + conn: Connection, +} + +impl OfflineStore { + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + + std::fs::create_dir_all(&data_dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + + let db_path = data_dir.join("offline_settings.db"); + let conn = Connection::open(&db_path) + .map_err(|e| format!("Failed to open offline settings database: {}", e))?; + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS offline_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + manual_offline_mode INTEGER NOT NULL DEFAULT 0, + show_partial_playlists INTEGER NOT NULL DEFAULT 1 + ); + INSERT OR IGNORE INTO offline_settings (id, manual_offline_mode, show_partial_playlists) + VALUES (1, 0, 1);" + ).map_err(|e| format!("Failed to create offline settings table: {}", e))?; + + Ok(Self { conn }) + } + + pub fn get_settings(&self) -> Result { + self.conn + .query_row( + "SELECT manual_offline_mode, show_partial_playlists FROM offline_settings WHERE id = 1", + [], + |row| { + Ok(OfflineSettings { + manual_offline_mode: row.get::<_, i64>(0)? != 0, + show_partial_playlists: row.get::<_, i64>(1)? != 0, + }) + }, + ) + .map_err(|e| format!("Failed to get offline settings: {}", e)) + } + + pub fn set_manual_offline_mode(&self, enabled: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE offline_settings SET manual_offline_mode = ?1 WHERE id = 1", + params![enabled as i64], + ) + .map_err(|e| format!("Failed to set manual offline mode: {}", e))?; + Ok(()) + } + + pub fn set_show_partial_playlists(&self, enabled: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE offline_settings SET show_partial_playlists = ?1 WHERE id = 1", + params![enabled as i64], + ) + .map_err(|e| format!("Failed to set show partial playlists: {}", e))?; + Ok(()) + } +} + +/// Thread-safe wrapper for OfflineStore +pub struct OfflineState { + pub store: Arc>, +} + +impl OfflineState { + pub fn new() -> Result { + Ok(Self { + store: Arc::new(Mutex::new(OfflineStore::new()?)), + }) + } +} + +/// Check network connectivity by attempting to reach Qobuz API +pub async fn check_network_connectivity() -> bool { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build(); + + let client = match client { + Ok(c) => c, + Err(_) => return false, + }; + + // Use HEAD request for minimal data transfer + match client.head("https://www.qobuz.com").send().await { + Ok(response) => response.status().is_success() || response.status().is_redirection(), + Err(_) => false, + } +} + +// Tauri commands +pub mod commands { + use super::*; + use crate::AppState; + use tauri::State; + + /// Get current offline status + #[tauri::command] + pub async fn get_offline_status( + offline_state: State<'_, OfflineState>, + app_state: State<'_, AppState>, + ) -> Result { + let settings = { + let store = offline_state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + store.get_settings()? + }; + + // If manual offline mode is enabled, return immediately + if settings.manual_offline_mode { + return Ok(OfflineStatus { + is_offline: true, + reason: Some(OfflineReason::ManualOverride), + manual_mode_enabled: true, + }); + } + + // Check if user is logged in + let is_logged_in = { + let client = app_state.client.lock().await; + client.is_logged_in().await + }; + + if !is_logged_in { + return Ok(OfflineStatus { + is_offline: true, + reason: Some(OfflineReason::NotLoggedIn), + manual_mode_enabled: false, + }); + } + + // Check network connectivity + let has_network = check_network_connectivity().await; + + if !has_network { + return Ok(OfflineStatus { + is_offline: true, + reason: Some(OfflineReason::NoNetwork), + manual_mode_enabled: false, + }); + } + + Ok(OfflineStatus { + is_offline: false, + reason: None, + manual_mode_enabled: false, + }) + } + + /// Get offline settings + #[tauri::command] + pub fn get_offline_settings( + state: State<'_, OfflineState>, + ) -> Result { + let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + store.get_settings() + } + + /// Enable or disable manual offline mode + #[tauri::command] + pub async fn set_manual_offline( + enabled: bool, + state: State<'_, OfflineState>, + app_state: State<'_, AppState>, + app_handle: AppHandle, + ) -> Result { + { + let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + store.set_manual_offline_mode(enabled)?; + } + + // Get updated status + let status = get_offline_status(state, app_state).await?; + + // Emit event to frontend + let _ = app_handle.emit("offline-status-changed", &status); + + Ok(status) + } + + /// Set whether to show playlists with partial local content in offline mode + #[tauri::command] + pub fn set_show_partial_playlists( + enabled: bool, + state: State<'_, OfflineState>, + ) -> Result<(), String> { + let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + store.set_show_partial_playlists(enabled) + } +}