Merge refactor/qbz-app-ui-preferences-foundation: qbz-app UI preference stores
cx
This commit is contained in:
6 files changed
+883
-670
No files matched your search
@@ -1,4 +1,6 @@
|
|||||||
pub mod favorites;
|
pub mod favorites;
|
||||||
pub mod legal;
|
pub mod legal;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
|
pub mod playback;
|
||||||
pub mod subscription;
|
pub mod subscription;
|
||||||
|
pub mod tray;
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
//! Playback and small playback-adjacent UI preferences.
|
||||||
|
//!
|
||||||
|
//! `show_context_icon` is persisted here to preserve the existing settings
|
||||||
|
//! contract, but it is a portable UI preference, not playback domain logic.
|
||||||
|
|
||||||
|
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(value: &str) -> Self {
|
||||||
|
match value {
|
||||||
|
"track_only" => AutoplayMode::PlayTrackOnly,
|
||||||
|
"infinite" => AutoplayMode::InfiniteRadio,
|
||||||
|
_ => AutoplayMode::ContinueWithinSource,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PlaybackPreferences {
|
||||||
|
pub autoplay_mode: AutoplayMode,
|
||||||
|
/// Portable UI preference for showing the context-stack icon.
|
||||||
|
/// This is not domain playback behavior.
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
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))?;
|
||||||
|
|
||||||
|
let show_context_icon_exists =
|
||||||
|
column_exists(&conn, "playback_preferences", "show_context_icon");
|
||||||
|
info!(
|
||||||
|
"[PlaybackPrefs] Column show_context_icon exists: {}",
|
||||||
|
show_context_icon_exists
|
||||||
|
);
|
||||||
|
if !show_context_icon_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");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !column_exists(&conn, "playback_preferences", "persist_session") {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !column_exists(&conn, "playback_preferences", "resume_playback_position") {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
|
||||||
|
conn.query_row(
|
||||||
|
&format!("SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = ?1"),
|
||||||
|
[column],
|
||||||
|
|row| {
|
||||||
|
let count: i32 = row.get(0)?;
|
||||||
|
Ok(count > 0)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fresh_store(name: &str) -> (std::path::PathBuf, PlaybackPreferencesStore) {
|
||||||
|
let dir = unique_test_dir(name);
|
||||||
|
let store = PlaybackPreferencesStore::new_at(&dir).expect("open store in temp dir");
|
||||||
|
(dir, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playback_preferences_default_values_are_stable() {
|
||||||
|
let prefs = PlaybackPreferences::default();
|
||||||
|
|
||||||
|
assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource);
|
||||||
|
assert!(!prefs.show_context_icon);
|
||||||
|
assert!(!prefs.persist_session);
|
||||||
|
assert!(!prefs.resume_playback_position);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playback_preferences_store_returns_defaults() {
|
||||||
|
let (dir, store) = fresh_store("playback-default");
|
||||||
|
|
||||||
|
let prefs = store.get_preferences().expect("get prefs");
|
||||||
|
|
||||||
|
assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource);
|
||||||
|
assert!(!prefs.show_context_icon);
|
||||||
|
assert!(!prefs.persist_session);
|
||||||
|
assert!(!prefs.resume_playback_position);
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playback_preferences_persist_all_fields() {
|
||||||
|
let dir = unique_test_dir("playback-persist");
|
||||||
|
{
|
||||||
|
let store = PlaybackPreferencesStore::new_at(&dir).expect("open store");
|
||||||
|
store
|
||||||
|
.set_autoplay_mode(AutoplayMode::InfiniteRadio)
|
||||||
|
.expect("set autoplay");
|
||||||
|
store.set_show_context_icon(true).expect("set context icon");
|
||||||
|
store
|
||||||
|
.set_persist_session(true)
|
||||||
|
.expect("set persist session");
|
||||||
|
store
|
||||||
|
.set_resume_playback_position(true)
|
||||||
|
.expect("set resume position");
|
||||||
|
}
|
||||||
|
|
||||||
|
let reopened = PlaybackPreferencesStore::new_at(&dir).expect("reopen store");
|
||||||
|
let prefs = reopened.get_preferences().expect("get prefs");
|
||||||
|
|
||||||
|
assert_eq!(prefs.autoplay_mode, AutoplayMode::InfiniteRadio);
|
||||||
|
assert!(prefs.show_context_icon);
|
||||||
|
assert!(prefs.persist_session);
|
||||||
|
assert!(prefs.resume_playback_position);
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playback_preferences_migrates_legacy_schema() {
|
||||||
|
let dir = unique_test_dir("playback-migrate");
|
||||||
|
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||||
|
let db_path = dir.join("playback_preferences.db");
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).expect("open legacy db");
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE playback_preferences (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
autoplay_mode TEXT NOT NULL DEFAULT 'continue'
|
||||||
|
);
|
||||||
|
INSERT INTO playback_preferences (id, autoplay_mode) VALUES (1, 'track_only');",
|
||||||
|
)
|
||||||
|
.expect("create legacy schema");
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = PlaybackPreferencesStore::new_at(&dir).expect("migrate store");
|
||||||
|
let prefs = store.get_preferences().expect("get prefs");
|
||||||
|
|
||||||
|
assert_eq!(prefs.autoplay_mode, AutoplayMode::PlayTrackOnly);
|
||||||
|
assert!(!prefs.show_context_icon);
|
||||||
|
assert!(!prefs.persist_session);
|
||||||
|
assert!(!prefs.resume_playback_position);
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playback_preferences_reset_all_preserves_existing_behavior() {
|
||||||
|
let (dir, store) = fresh_store("playback-reset");
|
||||||
|
store
|
||||||
|
.set_autoplay_mode(AutoplayMode::InfiniteRadio)
|
||||||
|
.expect("set autoplay");
|
||||||
|
store.set_show_context_icon(true).expect("set context icon");
|
||||||
|
store.set_persist_session(true).expect("set persist");
|
||||||
|
store
|
||||||
|
.set_resume_playback_position(true)
|
||||||
|
.expect("set resume position");
|
||||||
|
|
||||||
|
let defaults = store.reset_all().expect("reset prefs");
|
||||||
|
let prefs = store.get_preferences().expect("get prefs");
|
||||||
|
|
||||||
|
assert_eq!(defaults.autoplay_mode, AutoplayMode::ContinueWithinSource);
|
||||||
|
assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource);
|
||||||
|
assert!(!prefs.show_context_icon);
|
||||||
|
assert!(!prefs.persist_session);
|
||||||
|
assert!(!prefs.resume_playback_position);
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Tracks when a user was first observed without a valid subscription. If the
|
//! Tracks when a user was first observed without a valid subscription. If the
|
||||||
//! invalid state persists for more than the grace period, offline downloads are
|
//! invalid state persists for more than the grace period, offline downloads are
|
||||||
//! purged by the Tauri-side session lifecycle.
|
//! purged by the host-side session lifecycle.
|
||||||
|
|
||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
//! Portable tray preference storage.
|
||||||
|
//!
|
||||||
|
//! This module owns persisted tray preferences only. Runtime tray creation,
|
||||||
|
//! icon updates, window hiding/showing, and emitted events remain in the
|
||||||
|
//! host application layer.
|
||||||
|
|
||||||
|
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" follows system color scheme,
|
||||||
|
/// - "mono-light" uses a light glyph for dark panels,
|
||||||
|
/// - "mono-dark" uses a dark glyph for light panels,
|
||||||
|
/// - "color" uses the full-color 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. They are 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))?;
|
||||||
|
|
||||||
|
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))?;
|
||||||
|
|
||||||
|
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))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fresh_store(name: &str) -> (std::path::PathBuf, TraySettingsStore) {
|
||||||
|
let dir = unique_test_dir(name);
|
||||||
|
let store = TraySettingsStore::new_at(&dir).expect("open store in temp dir");
|
||||||
|
(dir, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tray_settings_default_values_are_stable() {
|
||||||
|
let settings = TraySettings::default();
|
||||||
|
|
||||||
|
assert!(settings.enable_tray);
|
||||||
|
assert!(!settings.minimize_to_tray);
|
||||||
|
assert!(!settings.close_to_tray);
|
||||||
|
assert_eq!(settings.tray_icon_theme, "auto");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tray_settings_store_returns_defaults() {
|
||||||
|
let (dir, store) = fresh_store("tray-default");
|
||||||
|
|
||||||
|
let settings = store.get_settings().expect("get settings");
|
||||||
|
|
||||||
|
assert!(settings.enable_tray);
|
||||||
|
assert!(!settings.minimize_to_tray);
|
||||||
|
assert!(!settings.close_to_tray);
|
||||||
|
assert_eq!(settings.tray_icon_theme, "auto");
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tray_settings_persist_all_fields() {
|
||||||
|
let dir = unique_test_dir("tray-persist");
|
||||||
|
{
|
||||||
|
let store = TraySettingsStore::new_at(&dir).expect("open store");
|
||||||
|
store.set_enable_tray(false).expect("set enable");
|
||||||
|
store.set_minimize_to_tray(true).expect("set minimize");
|
||||||
|
store.set_close_to_tray(true).expect("set close");
|
||||||
|
store
|
||||||
|
.set_tray_icon_theme("mono-light")
|
||||||
|
.expect("set icon theme");
|
||||||
|
}
|
||||||
|
|
||||||
|
let reopened = TraySettingsStore::new_at(&dir).expect("reopen store");
|
||||||
|
let settings = reopened.get_settings().expect("get settings");
|
||||||
|
|
||||||
|
assert!(!settings.enable_tray);
|
||||||
|
assert!(settings.minimize_to_tray);
|
||||||
|
assert!(settings.close_to_tray);
|
||||||
|
assert_eq!(settings.tray_icon_theme, "mono-light");
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tray_icon_theme_normalizes_supported_legacy_and_unknown_values() {
|
||||||
|
assert_eq!(normalize_tray_icon_theme("auto"), "auto");
|
||||||
|
assert_eq!(normalize_tray_icon_theme("mono-light"), "mono-light");
|
||||||
|
assert_eq!(normalize_tray_icon_theme("mono-dark"), "mono-dark");
|
||||||
|
assert_eq!(normalize_tray_icon_theme("color"), "color");
|
||||||
|
assert_eq!(normalize_tray_icon_theme("light"), "mono-light");
|
||||||
|
assert_eq!(normalize_tray_icon_theme("dark"), "mono-dark");
|
||||||
|
assert_eq!(normalize_tray_icon_theme("invalid"), "auto");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tray_settings_migrates_legacy_schema() {
|
||||||
|
let dir = unique_test_dir("tray-migrate");
|
||||||
|
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||||
|
let db_path = dir.join("tray_settings.db");
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).expect("open legacy db");
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE 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
|
||||||
|
);
|
||||||
|
INSERT INTO tray_settings (id, enable_tray, minimize_to_tray, close_to_tray)
|
||||||
|
VALUES (1, 0, 1, 1);",
|
||||||
|
)
|
||||||
|
.expect("create legacy schema");
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = TraySettingsStore::new_at(&dir).expect("migrate store");
|
||||||
|
let settings = store.get_settings().expect("get settings");
|
||||||
|
|
||||||
|
assert!(!settings.enable_tray);
|
||||||
|
assert!(settings.minimize_to_tray);
|
||||||
|
assert!(settings.close_to_tray);
|
||||||
|
assert_eq!(settings.tray_icon_theme, "auto");
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,364 +1,12 @@
|
|||||||
//! Playback preferences
|
//! Tauri commands for playback and playback-adjacent UI preferences.
|
||||||
//!
|
|
||||||
//! Stores user preferences for playback behavior (autoplay mode, etc.)
|
|
||||||
|
|
||||||
use log::info;
|
pub use qbz_app::settings::playback::{
|
||||||
use rusqlite::{params, Connection};
|
AutoplayMode, PlaybackPreferences, PlaybackPreferencesState, PlaybackPreferencesStore,
|
||||||
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
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_playback_preferences(
|
pub fn get_playback_preferences(
|
||||||
state: tauri::State<PlaybackPreferencesState>,
|
state: tauri::State<'_, PlaybackPreferencesState>,
|
||||||
) -> Result<PlaybackPreferences, String> {
|
) -> Result<PlaybackPreferences, String> {
|
||||||
state.get_preferences()
|
state.get_preferences()
|
||||||
}
|
}
|
||||||
@@ -366,7 +14,7 @@ pub fn get_playback_preferences(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_autoplay_mode(
|
pub fn set_autoplay_mode(
|
||||||
mode: String,
|
mode: String,
|
||||||
state: tauri::State<PlaybackPreferencesState>,
|
state: tauri::State<'_, PlaybackPreferencesState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let autoplay_mode = match mode.as_str() {
|
let autoplay_mode = match mode.as_str() {
|
||||||
"continue" => AutoplayMode::ContinueWithinSource,
|
"continue" => AutoplayMode::ContinueWithinSource,
|
||||||
@@ -380,7 +28,7 @@ pub fn set_autoplay_mode(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_show_context_icon(
|
pub fn set_show_context_icon(
|
||||||
show: bool,
|
show: bool,
|
||||||
state: tauri::State<PlaybackPreferencesState>,
|
state: tauri::State<'_, PlaybackPreferencesState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
state.set_show_context_icon(show)
|
state.set_show_context_icon(show)
|
||||||
}
|
}
|
||||||
@@ -388,7 +36,7 @@ pub fn set_show_context_icon(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_persist_session(
|
pub fn set_persist_session(
|
||||||
persist: bool,
|
persist: bool,
|
||||||
state: tauri::State<PlaybackPreferencesState>,
|
state: tauri::State<'_, PlaybackPreferencesState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
state.set_persist_session(persist)
|
state.set_persist_session(persist)
|
||||||
}
|
}
|
||||||
@@ -396,7 +44,7 @@ pub fn set_persist_session(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_resume_playback_position(
|
pub fn set_resume_playback_position(
|
||||||
resume: bool,
|
resume: bool,
|
||||||
state: tauri::State<PlaybackPreferencesState>,
|
state: tauri::State<'_, PlaybackPreferencesState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
state.set_resume_playback_position(resume)
|
state.set_resume_playback_position(resume)
|
||||||
}
|
}
|
||||||
@@ -1,308 +1,47 @@
|
|||||||
//! Tray icon settings
|
//! Tauri commands for tray settings.
|
||||||
//!
|
//!
|
||||||
//! Stores user preferences for system tray behavior:
|
//! Persisted tray preferences live in `qbz-app`. Runtime tray creation,
|
||||||
//! - enable_tray: Show tray icon (requires restart)
|
//! icon updates, window behavior, and emitted events remain Tauri-owned.
|
||||||
//! - minimize_to_tray: Hide to tray when minimizing
|
|
||||||
//! - close_to_tray: Hide to tray when closing window
|
use log::info;
|
||||||
|
|
||||||
use log::info;
|
pub use qbz_app::settings::tray::{
|
||||||
use rusqlite::{params, Connection};
|
normalize_tray_icon_theme, TraySettings, TraySettingsState, TraySettingsStore,
|
||||||
use serde::{Deserialize, Serialize};
|
};
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::{Arc, Mutex};
|
#[tauri::command]
|
||||||
|
pub fn get_tray_settings(
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
state: tauri::State<'_, TraySettingsState>,
|
||||||
pub struct TraySettings {
|
) -> Result<TraySettings, String> {
|
||||||
/// Show system tray icon (requires restart to take effect)
|
state.get_settings()
|
||||||
pub enable_tray: bool,
|
}
|
||||||
/// Hide window to tray when clicking minimize
|
|
||||||
pub minimize_to_tray: bool,
|
#[tauri::command]
|
||||||
/// Hide window to tray instead of quitting when clicking close
|
pub fn set_enable_tray(
|
||||||
pub close_to_tray: bool,
|
value: bool,
|
||||||
/// Tray icon variant override:
|
state: tauri::State<'_, TraySettingsState>,
|
||||||
/// - "auto" (default; follows system color scheme),
|
) -> Result<(), String> {
|
||||||
/// - "mono-light" (light-coloured glyph for dark panels — useful
|
info!(
|
||||||
/// on GNOME where the top bar is permanently dark),
|
"[TraySettings] Setting enable_tray to {} (restart required)",
|
||||||
/// - "mono-dark" (dark-coloured glyph for light panels),
|
value
|
||||||
/// - "color" (full colour vinyl logo).
|
);
|
||||||
#[serde(default = "default_tray_icon_theme")]
|
state.set_enable_tray(value)
|
||||||
pub tray_icon_theme: String,
|
}
|
||||||
}
|
|
||||||
|
#[tauri::command]
|
||||||
fn default_tray_icon_theme() -> String {
|
pub fn set_minimize_to_tray(
|
||||||
"auto".to_string()
|
value: bool,
|
||||||
}
|
state: tauri::State<'_, TraySettingsState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
/// Coerce free-form values to the supported set. Anything outside the
|
info!("[TraySettings] Setting minimize_to_tray to {}", value);
|
||||||
/// supported list falls back to "auto".
|
state.set_minimize_to_tray(value)
|
||||||
///
|
}
|
||||||
/// Legacy 1.2.9-pre values "light"/"dark" had inverted semantics
|
|
||||||
/// relative to the labels users saw (picking "Light" got the dark
|
#[tauri::command]
|
||||||
/// glyph and vice-versa). They're remapped here to the value that
|
pub fn set_close_to_tray(
|
||||||
/// matches the user's original intent.
|
value: bool,
|
||||||
pub fn normalize_tray_icon_theme(input: &str) -> String {
|
state: tauri::State<'_, TraySettingsState>,
|
||||||
match input {
|
) -> Result<(), String> {
|
||||||
"mono-light" | "mono-dark" | "color" | "auto" => input.to_string(),
|
info!("[TraySettings] Setting close_to_tray to {}", value);
|
||||||
"light" => "mono-light".to_string(),
|
state.set_close_to_tray(value)
|
||||||
"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)
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user