refactor(tauri): use qbz-app ui preference stores

cx
This commit is contained in:
vicrodh committed 2026-05-15 00:10:57 -06:00
1 parent d1082317f5
commit 2d7639c637
2 files changed
+56 -669

No files matched your search

+9 -361
View File
@@ -1,364 +1,12 @@
//! Playback preferences
//!
//! Stores user preferences for playback behavior (autoplay mode, etc.)
//! Tauri commands for playback and playback-adjacent UI preferences.
use log::info;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AutoplayMode {
/// Continue playing within the source (album, playlist, etc.)
#[serde(rename = "continue")]
ContinueWithinSource,
/// Play only the selected track, then stop
#[serde(rename = "track_only")]
PlayTrackOnly,
/// Create infinite radio when queue ends (based on recent tracks)
#[serde(rename = "infinite")]
InfiniteRadio,
}
impl Default for AutoplayMode {
fn default() -> Self {
Self::ContinueWithinSource
}
}
impl AutoplayMode {
fn to_db_value(&self) -> &'static str {
match self {
AutoplayMode::ContinueWithinSource => "continue",
AutoplayMode::PlayTrackOnly => "track_only",
AutoplayMode::InfiniteRadio => "infinite",
}
}
fn from_db_value(s: &str) -> Self {
match s {
"track_only" => AutoplayMode::PlayTrackOnly,
"infinite" => AutoplayMode::InfiniteRadio,
_ => AutoplayMode::ContinueWithinSource,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybackPreferences {
pub autoplay_mode: AutoplayMode,
pub show_context_icon: bool,
pub persist_session: bool,
/// Sub-preference of `persist_session`. When true, restoring a
/// session also seeks to `current_position_secs` of the saved
/// track. When false (default), the saved track is shown paused at
/// 0:00 and the user starts the next listen fresh — the common
/// preference for "open app the next morning to a clean slate"
/// (issue #317).
pub resume_playback_position: bool,
}
impl Default for PlaybackPreferences {
fn default() -> Self {
Self {
autoplay_mode: AutoplayMode::ContinueWithinSource,
show_context_icon: false,
persist_session: false,
resume_playback_position: false,
}
}
}
pub struct PlaybackPreferencesStore {
conn: Connection,
}
impl PlaybackPreferencesStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open playback preferences database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| {
format!(
"Failed to enable WAL for playback preferences database: {}",
e
)
})?;
// Step 1: Create table with old schema (for new installs) or do nothing if exists
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS playback_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
autoplay_mode TEXT NOT NULL DEFAULT 'continue'
);",
)
.map_err(|e| format!("Failed to create playback preferences table: {}", e))?;
// Step 2: Check if show_context_icon column exists
let column_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('playback_preferences') WHERE name='show_context_icon'",
[],
|row| {
let count: i32 = row.get(0)?;
Ok(count > 0)
}
)
.unwrap_or(false);
info!(
"[PlaybackPrefs] Column show_context_icon exists: {}",
column_exists
);
// Step 3: Add column if it doesn't exist (migration for existing users)
if !column_exists {
info!("[PlaybackPrefs] Migrating: adding show_context_icon column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN show_context_icon INTEGER NOT NULL DEFAULT 0",
[]
).map_err(|e| format!("Failed to add show_context_icon column: {}", e))?;
info!("[PlaybackPrefs] Migration successful");
}
// Step 3b: Check if persist_session column exists
let persist_session_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('playback_preferences') WHERE name='persist_session'",
[],
|row| {
let count: i32 = row.get(0)?;
Ok(count > 0)
}
)
.unwrap_or(false);
if !persist_session_exists {
info!("[PlaybackPrefs] Migrating: adding persist_session column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN persist_session INTEGER NOT NULL DEFAULT 0",
[]
).map_err(|e| format!("Failed to add persist_session column: {}", e))?;
info!("[PlaybackPrefs] persist_session migration successful");
}
// Step 3c: Check if resume_playback_position column exists
let resume_position_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('playback_preferences') WHERE name='resume_playback_position'",
[],
|row| {
let count: i32 = row.get(0)?;
Ok(count > 0)
}
)
.unwrap_or(false);
if !resume_position_exists {
info!("[PlaybackPrefs] Migrating: adding resume_playback_position column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN resume_playback_position INTEGER NOT NULL DEFAULT 0",
[]
).map_err(|e| format!("Failed to add resume_playback_position column: {}", e))?;
info!("[PlaybackPrefs] resume_playback_position migration successful");
}
// Step 4: Insert default row if it doesn't exist
conn.execute(
"INSERT OR IGNORE INTO playback_preferences (id, autoplay_mode, show_context_icon, persist_session, resume_playback_position)
VALUES (1, 'continue', 0, 0, 0)",
[],
)
.map_err(|e| format!("Failed to insert default preferences: {}", e))?;
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "playback_preferences.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "playback_preferences.db")
}
pub fn get_preferences(&self) -> Result<PlaybackPreferences, String> {
self.conn
.query_row(
"SELECT autoplay_mode, show_context_icon, persist_session, resume_playback_position FROM playback_preferences WHERE id = 1",
[],
|row| {
let autoplay_str: String = row.get(0)?;
let show_icon: i32 = row.get(1)?;
let persist: i32 = row.get(2)?;
let resume_pos: i32 = row.get(3)?;
Ok(PlaybackPreferences {
autoplay_mode: AutoplayMode::from_db_value(&autoplay_str),
show_context_icon: show_icon != 0,
persist_session: persist != 0,
resume_playback_position: resume_pos != 0,
})
},
)
.map_err(|e| format!("Failed to get playback preferences: {}", e))
}
pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET autoplay_mode = ?1 WHERE id = 1",
params![mode.to_db_value()],
)
.map_err(|e| format!("Failed to set autoplay mode: {}", e))?;
Ok(())
}
pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET show_context_icon = ?1 WHERE id = 1",
params![if show { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set show context icon: {}", e))?;
Ok(())
}
pub fn set_persist_session(&self, persist: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET persist_session = ?1 WHERE id = 1",
params![if persist { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set persist session: {}", e))?;
Ok(())
}
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET resume_playback_position = ?1 WHERE id = 1",
params![if resume { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set resume playback position: {}", e))?;
Ok(())
}
/// Reset all playback preferences to their default values
pub fn reset_all(&self) -> Result<PlaybackPreferences, String> {
let defaults = PlaybackPreferences::default();
self.conn
.execute(
"UPDATE playback_preferences SET autoplay_mode = ?1, show_context_icon = ?2, persist_session = ?3, resume_playback_position = ?4 WHERE id = 1",
params![
defaults.autoplay_mode.to_db_value(),
if defaults.show_context_icon { 1 } else { 0 },
if defaults.persist_session { 1 } else { 0 },
if defaults.resume_playback_position { 1 } else { 0 },
],
)
.map_err(|e| format!("Failed to reset playback preferences: {}", e))?;
Ok(defaults)
}
}
/// Global state wrapper for thread-safe access
pub struct PlaybackPreferencesState {
pub store: Arc<Mutex<Option<PlaybackPreferencesStore>>>,
}
impl PlaybackPreferencesState {
pub fn new() -> Result<Self, String> {
let store = PlaybackPreferencesStore::new()?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = PlaybackPreferencesStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
*guard = None;
Ok(())
}
pub fn get_preferences(&self) -> Result<PlaybackPreferences, String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_preferences()
}
pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_autoplay_mode(mode)
}
pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_show_context_icon(show)
}
pub fn set_persist_session(&self, persist: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_persist_session(persist)
}
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_resume_playback_position(resume)
}
}
// Tauri commands
pub use qbz_app::settings::playback::{
AutoplayMode, PlaybackPreferences, PlaybackPreferencesState, PlaybackPreferencesStore,
};
#[tauri::command]
pub fn get_playback_preferences(
state: tauri::State<PlaybackPreferencesState>,
state: tauri::State<'_, PlaybackPreferencesState>,
) -> Result<PlaybackPreferences, String> {
state.get_preferences()
}
@@ -366,7 +14,7 @@ pub fn get_playback_preferences(
#[tauri::command]
pub fn set_autoplay_mode(
mode: String,
state: tauri::State<PlaybackPreferencesState>,
state: tauri::State<'_, PlaybackPreferencesState>,
) -> Result<(), String> {
let autoplay_mode = match mode.as_str() {
"continue" => AutoplayMode::ContinueWithinSource,
@@ -380,7 +28,7 @@ pub fn set_autoplay_mode(
#[tauri::command]
pub fn set_show_context_icon(
show: bool,
state: tauri::State<PlaybackPreferencesState>,
state: tauri::State<'_, PlaybackPreferencesState>,
) -> Result<(), String> {
state.set_show_context_icon(show)
}
@@ -388,7 +36,7 @@ pub fn set_show_context_icon(
#[tauri::command]
pub fn set_persist_session(
persist: bool,
state: tauri::State<PlaybackPreferencesState>,
state: tauri::State<'_, PlaybackPreferencesState>,
) -> Result<(), String> {
state.set_persist_session(persist)
}
@@ -396,7 +44,7 @@ pub fn set_persist_session(
#[tauri::command]
pub fn set_resume_playback_position(
resume: bool,
state: tauri::State<PlaybackPreferencesState>,
state: tauri::State<'_, PlaybackPreferencesState>,
) -> Result<(), String> {
state.set_resume_playback_position(resume)
}
+47 -308
View File
@@ -1,308 +1,47 @@
//! Tray icon settings
//!
//! Stores user preferences for system tray behavior:
//! - enable_tray: Show tray icon (requires restart)
//! - minimize_to_tray: Hide to tray when minimizing
//! - close_to_tray: Hide to tray when closing window
use log::info;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraySettings {
/// Show system tray icon (requires restart to take effect)
pub enable_tray: bool,
/// Hide window to tray when clicking minimize
pub minimize_to_tray: bool,
/// Hide window to tray instead of quitting when clicking close
pub close_to_tray: bool,
/// Tray icon variant override:
/// - "auto" (default; follows system color scheme),
/// - "mono-light" (light-coloured glyph for dark panels — useful
/// on GNOME where the top bar is permanently dark),
/// - "mono-dark" (dark-coloured glyph for light panels),
/// - "color" (full colour vinyl logo).
#[serde(default = "default_tray_icon_theme")]
pub tray_icon_theme: String,
}
fn default_tray_icon_theme() -> String {
"auto".to_string()
}
/// Coerce free-form values to the supported set. Anything outside the
/// supported list falls back to "auto".
///
/// Legacy 1.2.9-pre values "light"/"dark" had inverted semantics
/// relative to the labels users saw (picking "Light" got the dark
/// glyph and vice-versa). They're remapped here to the value that
/// matches the user's original intent.
pub fn normalize_tray_icon_theme(input: &str) -> String {
match input {
"mono-light" | "mono-dark" | "color" | "auto" => input.to_string(),
"light" => "mono-light".to_string(),
"dark" => "mono-dark".to_string(),
_ => "auto".to_string(),
}
}
impl Default for TraySettings {
fn default() -> Self {
Self {
enable_tray: true,
minimize_to_tray: false,
close_to_tray: false,
tray_icon_theme: default_tray_icon_theme(),
}
}
}
pub struct TraySettingsStore {
conn: Connection,
}
impl TraySettingsStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open tray settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for tray settings database: {}", e))?;
// Create table
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tray_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enable_tray INTEGER NOT NULL DEFAULT 1,
minimize_to_tray INTEGER NOT NULL DEFAULT 0,
close_to_tray INTEGER NOT NULL DEFAULT 0
);",
)
.map_err(|e| format!("Failed to create tray settings table: {}", e))?;
// Migration: add tray_icon_theme column if it doesn't exist (older DBs).
let has_theme_column: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('tray_settings') WHERE name = 'tray_icon_theme'",
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to check tray_icon_theme column: {}", e))?;
if has_theme_column == 0 {
conn.execute_batch(
"ALTER TABLE tray_settings ADD COLUMN tray_icon_theme TEXT NOT NULL DEFAULT 'auto';",
)
.map_err(|e| format!("Failed to add tray_icon_theme column: {}", e))?;
}
// Insert default row if it doesn't exist
conn.execute(
"INSERT OR IGNORE INTO tray_settings (id, enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme)
VALUES (1, 1, 0, 0, 'auto')",
[],
)
.map_err(|e| format!("Failed to insert default tray settings: {}", e))?;
info!("[TraySettings] Database initialized");
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "tray_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "tray_settings.db")
}
pub fn get_settings(&self) -> Result<TraySettings, String> {
self.conn
.query_row(
"SELECT enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme FROM tray_settings WHERE id = 1",
[],
|row| {
let enable_tray: i32 = row.get(0)?;
let minimize_to_tray: i32 = row.get(1)?;
let close_to_tray: i32 = row.get(2)?;
let tray_icon_theme: String = row.get(3)?;
Ok(TraySettings {
enable_tray: enable_tray != 0,
minimize_to_tray: minimize_to_tray != 0,
close_to_tray: close_to_tray != 0,
tray_icon_theme: normalize_tray_icon_theme(&tray_icon_theme),
})
},
)
.map_err(|e| format!("Failed to get tray settings: {}", e))
}
pub fn set_enable_tray(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE tray_settings SET enable_tray = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set enable_tray: {}", e))?;
Ok(())
}
pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE tray_settings SET minimize_to_tray = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set minimize_to_tray: {}", e))?;
Ok(())
}
pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE tray_settings SET close_to_tray = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set close_to_tray: {}", e))?;
Ok(())
}
pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> {
let normalized = normalize_tray_icon_theme(value);
self.conn
.execute(
"UPDATE tray_settings SET tray_icon_theme = ?1 WHERE id = 1",
params![normalized],
)
.map_err(|e| format!("Failed to set tray_icon_theme: {}", e))?;
Ok(())
}
}
/// Global state wrapper for thread-safe access
pub struct TraySettingsState {
pub store: Arc<Mutex<Option<TraySettingsStore>>>,
}
impl TraySettingsState {
pub fn new() -> Result<Self, String> {
let store = TraySettingsStore::new()?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = TraySettingsStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
*guard = None;
Ok(())
}
pub fn get_settings(&self) -> Result<TraySettings, String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_settings()
}
pub fn set_enable_tray(&self, value: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_enable_tray(value)
}
pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_minimize_to_tray(value)
}
pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_close_to_tray(value)
}
pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_tray_icon_theme(value)
}
}
// Tauri commands
#[tauri::command]
pub fn get_tray_settings(state: tauri::State<TraySettingsState>) -> Result<TraySettings, String> {
state.get_settings()
}
#[tauri::command]
pub fn set_enable_tray(value: bool, state: tauri::State<TraySettingsState>) -> Result<(), String> {
info!(
"[TraySettings] Setting enable_tray to {} (restart required)",
value
);
state.set_enable_tray(value)
}
#[tauri::command]
pub fn set_minimize_to_tray(
value: bool,
state: tauri::State<TraySettingsState>,
) -> Result<(), String> {
info!("[TraySettings] Setting minimize_to_tray to {}", value);
state.set_minimize_to_tray(value)
}
#[tauri::command]
pub fn set_close_to_tray(
value: bool,
state: tauri::State<TraySettingsState>,
) -> Result<(), String> {
info!("[TraySettings] Setting close_to_tray to {}", value);
state.set_close_to_tray(value)
}
//! Tauri commands for tray settings.
//!
//! Persisted tray preferences live in `qbz-app`. Runtime tray creation,
//! icon updates, window behavior, and emitted events remain Tauri-owned.
use log::info;
pub use qbz_app::settings::tray::{
normalize_tray_icon_theme, TraySettings, TraySettingsState, TraySettingsStore,
};
#[tauri::command]
pub fn get_tray_settings(
state: tauri::State<'_, TraySettingsState>,
) -> Result<TraySettings, String> {
state.get_settings()
}
#[tauri::command]
pub fn set_enable_tray(
value: bool,
state: tauri::State<'_, TraySettingsState>,
) -> Result<(), String> {
info!(
"[TraySettings] Setting enable_tray to {} (restart required)",
value
);
state.set_enable_tray(value)
}
#[tauri::command]
pub fn set_minimize_to_tray(
value: bool,
state: tauri::State<'_, TraySettingsState>,
) -> Result<(), String> {
info!("[TraySettings] Setting minimize_to_tray to {}", value);
state.set_minimize_to_tray(value)
}
#[tauri::command]
pub fn set_close_to_tray(
value: bool,
state: tauri::State<'_, TraySettingsState>,
) -> Result<(), String> {
info!("[TraySettings] Setting close_to_tray to {}", value);
state.set_close_to_tray(value)
}