The initial implementation added a dedicated get_release_watch client method calling /albums/releaseWatch, but that path is only routed on the mobile API; the web API (api.json/0.2) that qbz-qobuz speaks to returned 'no Service found with those values'. The correct source is already available: /discover/index — which we were already calling for newReleases/pressAwards/qobuzissimes/etc — also returns a release_watch container when the user has follows. We were silently dropping it because DiscoverContainers didn't declare the field. Now: - DiscoverContainers (Rust + TS mirror) declares the release_watch field with a serde alias that accepts both camelCase and snake_case spellings, so future naming churn upstream is tolerated - HomeView reads it in fetchAllDiscoverData next to the other editorial sections (one less network round-trip) - ForYouTab calls v2_get_discover_index and extracts only the release_watch container - Reverted the custom /albums/releaseWatch endpoint, client method, core passthrough, bridge wrapper and v2_get_release_watch command Section identity (homeSettingsStore id, i18n keys, render blocks) is unchanged. cc
1806 lines
78 KiB
Rust
1806 lines
78 KiB
Rust
//! QBZ-NIX: Native high-fidelity streaming client for Linux
|
|
//!
|
|
//! This application uses the Qobuz API but is not certified by Qobuz.
|
|
|
|
// New multi-crate architecture
|
|
pub mod commands_v2;
|
|
pub mod core_bridge;
|
|
pub mod integrations_v2;
|
|
pub mod qconnect_service;
|
|
pub mod runtime;
|
|
pub mod session_lifecycle;
|
|
pub mod tauri_adapter;
|
|
|
|
pub mod auto_theme;
|
|
#[cfg(target_os = "linux")]
|
|
pub mod autoconfig_graphics;
|
|
|
|
pub mod api;
|
|
pub mod api_cache;
|
|
pub mod api_server;
|
|
pub mod artist_blacklist;
|
|
pub mod artist_vectors;
|
|
pub mod audio;
|
|
pub mod cache;
|
|
pub mod cast;
|
|
pub mod commands;
|
|
pub mod config;
|
|
pub mod credentials;
|
|
pub mod discogs;
|
|
pub mod flatpak;
|
|
#[cfg(target_os = "linux")]
|
|
pub mod idle_inhibit;
|
|
pub mod image_cache;
|
|
pub mod lastfm;
|
|
pub mod library;
|
|
pub mod listenbrainz;
|
|
pub mod log_sanitize;
|
|
pub mod logging;
|
|
pub mod lyrics;
|
|
pub mod media_controls;
|
|
pub mod migration;
|
|
pub mod musicbrainz;
|
|
pub mod network;
|
|
pub mod offline;
|
|
pub mod offline_cache;
|
|
pub mod pdf_viewer;
|
|
pub mod playback_context;
|
|
#[allow(deprecated)]
|
|
pub mod player;
|
|
pub mod playlist_import;
|
|
pub mod plex;
|
|
pub mod queue;
|
|
pub mod radio_engine;
|
|
pub mod reco_store;
|
|
pub mod session_store;
|
|
pub mod share;
|
|
pub mod snap;
|
|
pub mod tray;
|
|
#[cfg(target_os = "linux")]
|
|
pub mod tray_linux_ksni;
|
|
pub mod updates;
|
|
pub mod user_data;
|
|
pub mod visualizer;
|
|
pub mod qbzd_discovery;
|
|
|
|
use rustls::crypto::{aws_lc_rs, CryptoProvider};
|
|
use std::sync::Arc;
|
|
use tauri::{Emitter, Manager};
|
|
use tokio::sync::{Mutex, RwLock};
|
|
|
|
use api::QobuzClient;
|
|
use cache::{AudioCache, PlaybackCache};
|
|
use lastfm::LastFmClient;
|
|
use media_controls::{MediaControlsManager, TrackInfo};
|
|
use playback_context::ContextManager;
|
|
use player::Player;
|
|
use queue::QueueManager;
|
|
use share::SongLinkClient;
|
|
use visualizer::Visualizer;
|
|
|
|
/// Application state shared across commands
|
|
pub struct AppState {
|
|
pub client: Arc<RwLock<QobuzClient>>,
|
|
pub player: Player,
|
|
pub queue: QueueManager,
|
|
pub context: ContextManager,
|
|
pub media_controls: MediaControlsManager,
|
|
pub audio_cache: Arc<AudioCache>,
|
|
pub lastfm: Arc<Mutex<LastFmClient>>,
|
|
pub songlink: SongLinkClient,
|
|
pub visualizer: Visualizer,
|
|
#[cfg(target_os = "linux")]
|
|
pub idle_inhibitor: idle_inhibit::IdleInhibitor,
|
|
}
|
|
|
|
impl AppState {
|
|
pub fn new() -> Self {
|
|
Self::with_device_and_settings(None, config::audio_settings::AudioSettings::default())
|
|
}
|
|
|
|
pub fn with_device(device_name: Option<String>) -> Self {
|
|
Self::with_device_and_settings(
|
|
device_name,
|
|
config::audio_settings::AudioSettings::default(),
|
|
)
|
|
}
|
|
|
|
pub fn with_device_and_settings(
|
|
device_name: Option<String>,
|
|
audio_settings: config::audio_settings::AudioSettings,
|
|
) -> Self {
|
|
// Create playback cache (L2 - disk, 800MB)
|
|
let playback_cache = match PlaybackCache::new(800 * 1024 * 1024) {
|
|
Ok(cache) => Some(Arc::new(cache)),
|
|
Err(e) => {
|
|
log::warn!(
|
|
"Failed to create playback cache: {}. Disk spillover disabled.",
|
|
e
|
|
);
|
|
None
|
|
}
|
|
};
|
|
|
|
// Create audio cache (L1 - memory, 400MB) with optional disk spillover
|
|
let audio_cache = if let Some(pc) = playback_cache {
|
|
Arc::new(AudioCache::with_playback_cache(400 * 1024 * 1024, pc))
|
|
} else {
|
|
Arc::new(AudioCache::default())
|
|
};
|
|
|
|
// Create visualizer first to get the tap for the player
|
|
let visualizer = Visualizer::new();
|
|
let viz_tap = visualizer.get_tap();
|
|
|
|
Self {
|
|
client: Arc::new(RwLock::new(QobuzClient::default())),
|
|
player: Player::new(
|
|
device_name,
|
|
audio_settings,
|
|
Some(viz_tap),
|
|
audio::AudioDiagnostic::new(),
|
|
),
|
|
queue: QueueManager::new(),
|
|
context: ContextManager::new(),
|
|
media_controls: MediaControlsManager::new(),
|
|
audio_cache,
|
|
lastfm: Arc::new(Mutex::new(LastFmClient::default())),
|
|
songlink: SongLinkClient::new(),
|
|
visualizer,
|
|
#[cfg(target_os = "linux")]
|
|
idle_inhibitor: idle_inhibit::IdleInhibitor::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for AppState {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Update MPRIS metadata when track changes
|
|
pub fn update_media_controls_metadata(
|
|
media_controls: &MediaControlsManager,
|
|
title: &str,
|
|
artist: &str,
|
|
album: &str,
|
|
duration_secs: Option<u64>,
|
|
cover_url: Option<String>,
|
|
) {
|
|
let track_info = TrackInfo {
|
|
title: title.to_string(),
|
|
artist: artist.to_string(),
|
|
album: album.to_string(),
|
|
duration_secs,
|
|
cover_url,
|
|
};
|
|
media_controls.set_metadata(&track_info);
|
|
}
|
|
|
|
/// DEPRECATED: KWin SSD hack removed — caused double titlebar + CPU spike.
|
|
/// Kept temporarily for the cleanup block that removes stale rules.
|
|
#[cfg(target_os = "linux")]
|
|
#[allow(dead_code)]
|
|
fn setup_kwin_window_rule() -> Result<(), String> {
|
|
let config_path = dirs::config_dir()
|
|
.ok_or_else(|| "Could not determine config directory".to_string())?
|
|
.join("kwinrulesrc");
|
|
|
|
// Read existing rules to find next available slot and check for existing QBZ rule
|
|
let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
|
|
let mut existing_count: u32 = 0;
|
|
let mut qbz_rule_group: Option<u32> = None;
|
|
|
|
for line in existing.lines() {
|
|
// Parse [General] count=N
|
|
if line.starts_with("count=") {
|
|
if let Ok(n) = line.trim_start_matches("count=").parse::<u32>() {
|
|
existing_count = n;
|
|
}
|
|
}
|
|
// Check if we already have a QBZ rule
|
|
if line.contains("Description=QBZ Native Title Bar") {
|
|
// Find the group number from context — we'll just scan backwards
|
|
// Simpler: track current group
|
|
}
|
|
}
|
|
|
|
// Parse INI to find existing QBZ rule group
|
|
let mut current_group: Option<u32> = None;
|
|
for line in existing.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
|
let inner = &trimmed[1..trimmed.len() - 1];
|
|
current_group = inner.parse::<u32>().ok();
|
|
}
|
|
if trimmed == "Description=QBZ Native Title Bar" {
|
|
qbz_rule_group = current_group;
|
|
}
|
|
}
|
|
|
|
let rule_num = if let Some(num) = qbz_rule_group {
|
|
log::info!(
|
|
"KWin window rule for QBZ already exists (group {}), updating",
|
|
num
|
|
);
|
|
num
|
|
} else {
|
|
existing_count + 1
|
|
};
|
|
|
|
// Write the rule using kwriteconfig6
|
|
let group = rule_num.to_string();
|
|
let rules: &[(&str, &str)] = &[
|
|
("Description", "QBZ Native Title Bar"),
|
|
("noborder", "false"),
|
|
("noborderrule", "2"), // 2 = Force
|
|
("wmclass", "qbz"),
|
|
("wmclasscomplete", "false"),
|
|
("wmclassmatch", "1"), // 1 = Exact match
|
|
("types", "1"), // 1 = Normal windows
|
|
];
|
|
|
|
for (key, value) in rules {
|
|
let output = std::process::Command::new("kwriteconfig6")
|
|
.args([
|
|
"--file",
|
|
"kwinrulesrc",
|
|
"--group",
|
|
&group,
|
|
"--key",
|
|
key,
|
|
value,
|
|
])
|
|
.output()
|
|
.map_err(|e| format!("kwriteconfig6 failed: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"kwriteconfig6 --key {} failed: {}",
|
|
key,
|
|
String::from_utf8_lossy(&output.stderr)
|
|
));
|
|
}
|
|
}
|
|
|
|
// Update the count in [General] if we added a new rule
|
|
if qbz_rule_group.is_none() {
|
|
let new_count = (existing_count + 1).to_string();
|
|
let output = std::process::Command::new("kwriteconfig6")
|
|
.args([
|
|
"--file",
|
|
"kwinrulesrc",
|
|
"--group",
|
|
"General",
|
|
"--key",
|
|
"count",
|
|
&new_count,
|
|
])
|
|
.output()
|
|
.map_err(|e| format!("kwriteconfig6 count update failed: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"kwriteconfig6 count failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
));
|
|
}
|
|
}
|
|
|
|
// Tell KWin to reload rules
|
|
let output = std::process::Command::new("qdbus6")
|
|
.args(["org.kde.KWin", "/KWin", "org.kde.KWin.reconfigure"])
|
|
.output()
|
|
.map_err(|e| format!("qdbus6 reconfigure failed: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
log::warn!(
|
|
"KWin reconfigure failed (non-fatal): {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
log::info!(
|
|
"KWin window rule set for native title bar (group {})",
|
|
rule_num
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove the KWin window rule for QBZ when system title bar is disabled.
|
|
#[cfg(target_os = "linux")]
|
|
#[allow(dead_code)]
|
|
fn remove_kwin_window_rule() {
|
|
// Find and remove QBZ rule from kwinrulesrc
|
|
let config_path = match dirs::config_dir() {
|
|
Some(p) => p.join("kwinrulesrc"),
|
|
None => return,
|
|
};
|
|
|
|
let existing = match std::fs::read_to_string(&config_path) {
|
|
Ok(s) => s,
|
|
Err(_) => return,
|
|
};
|
|
|
|
// Find the QBZ rule group number
|
|
let mut current_group: Option<String> = None;
|
|
let mut qbz_group: Option<String> = None;
|
|
|
|
for line in existing.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
|
current_group = Some(trimmed[1..trimmed.len() - 1].to_string());
|
|
}
|
|
if trimmed == "Description=QBZ Native Title Bar" {
|
|
qbz_group = current_group.clone();
|
|
}
|
|
}
|
|
|
|
if let Some(group) = qbz_group {
|
|
// Delete the group using kwriteconfig6
|
|
let _ = std::process::Command::new("kwriteconfig6")
|
|
.args(["--file", "kwinrulesrc", "--group", &group, "--delete-group"])
|
|
.output();
|
|
|
|
// Decrement count
|
|
let mut count: u32 = 0;
|
|
for line in existing.lines() {
|
|
if line.starts_with("count=") {
|
|
count = line.trim_start_matches("count=").parse().unwrap_or(0);
|
|
}
|
|
}
|
|
if count > 0 {
|
|
let new_count = (count - 1).to_string();
|
|
let _ = std::process::Command::new("kwriteconfig6")
|
|
.args([
|
|
"--file",
|
|
"kwinrulesrc",
|
|
"--group",
|
|
"General",
|
|
"--key",
|
|
"count",
|
|
&new_count,
|
|
])
|
|
.output();
|
|
}
|
|
|
|
// Reconfigure KWin
|
|
let _ = std::process::Command::new("qdbus6")
|
|
.args(["org.kde.KWin", "/KWin", "org.kde.KWin.reconfigure"])
|
|
.output();
|
|
|
|
log::info!("KWin window rule for QBZ removed");
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn apply_linux_webkit_workarounds() {
|
|
// NOTE: This function runs BEFORE main.rs GPU detection.
|
|
// It must NOT set WEBKIT_DISABLE_DMABUF_RENDERER or WEBKIT_DISABLE_COMPOSITING_MODE
|
|
// because main.rs handles those with full GPU detection context.
|
|
//
|
|
// This function only handles the legacy QBZ_WEBKIT_FORCE_GPU env var.
|
|
|
|
let force_gpu = std::env::var("QBZ_WEBKIT_FORCE_GPU")
|
|
.map(|v| {
|
|
let n = v.trim().to_ascii_lowercase();
|
|
n == "1" || n == "true" || n == "yes"
|
|
})
|
|
.unwrap_or(false);
|
|
|
|
if force_gpu {
|
|
log::warn!("QBZ_WEBKIT_FORCE_GPU is enabled; main.rs GPU logic will be skipped");
|
|
// Set markers so main.rs knows to skip its logic
|
|
std::env::set_var("QBZ_HARDWARE_ACCEL", "1");
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn should_use_main_window_transparency() -> bool {
|
|
let force_transparent = std::env::var("QBZ_FORCE_TRANSPARENT_WINDOWS")
|
|
.map(|value| {
|
|
let normalized = value.trim().to_ascii_lowercase();
|
|
normalized == "1" || normalized == "true" || normalized == "yes"
|
|
})
|
|
.unwrap_or(false);
|
|
if force_transparent {
|
|
return true;
|
|
}
|
|
|
|
let force_opaque = std::env::var("QBZ_FORCE_OPAQUE_WINDOWS")
|
|
.map(|value| {
|
|
let normalized = value.trim().to_ascii_lowercase();
|
|
normalized == "1" || normalized == "true" || normalized == "yes"
|
|
})
|
|
.unwrap_or(false);
|
|
if force_opaque {
|
|
return false;
|
|
}
|
|
|
|
// Stability-first default on Linux.
|
|
false
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
fn should_use_main_window_transparency() -> bool {
|
|
// On macOS, transparent WKWebView causes severe rendering performance issues
|
|
// (every frame must be composited through the transparent background).
|
|
// Default to opaque unless explicitly requested.
|
|
std::env::var("QBZ_FORCE_TRANSPARENT_WINDOWS")
|
|
.map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Check if a string looks like a Qobuz link (custom scheme or web URL).
|
|
fn is_qobuz_link(s: &str) -> bool {
|
|
s.starts_with("qobuzapp://")
|
|
|| s.starts_with("https://play.qobuz.com/")
|
|
|| s.starts_with("http://play.qobuz.com/")
|
|
|| s.starts_with("https://open.qobuz.com/")
|
|
|| s.starts_with("http://open.qobuz.com/")
|
|
}
|
|
|
|
/// Resolve a Qobuz link and emit the result to the frontend.
|
|
/// If `delay` is true, waits 1500ms to give the frontend time to mount (first launch).
|
|
fn handle_qobuz_link(handle: &tauri::AppHandle, url: &str, delay: bool) {
|
|
if !is_qobuz_link(url) {
|
|
return;
|
|
}
|
|
match qbz_qobuz::resolve_link(url) {
|
|
Ok(resolved) => {
|
|
log::info!("[Link] Resolved: {:?}", resolved);
|
|
if delay {
|
|
let h = handle.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
|
if let Err(e) = h.emit("link:resolved", &resolved) {
|
|
log::error!("[Link] Failed to emit resolved link: {}", e);
|
|
}
|
|
});
|
|
} else if let Err(e) = handle.emit("link:resolved", &resolved) {
|
|
log::error!("[Link] Failed to emit resolved link: {}", e);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::debug!("[Link] Failed to resolve '{}': {}", url.split('?').next().unwrap_or(url), e);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn run() {
|
|
// Load .env file if present (for development)
|
|
// Silently ignore if not found (production builds use compile-time env vars)
|
|
dotenvy::dotenv().ok();
|
|
|
|
// rustls 0.23 requires an explicit process-level crypto provider when
|
|
// multiple provider features are present in the dependency graph.
|
|
if CryptoProvider::get_default().is_none() {
|
|
let _ = aws_lc_rs::default_provider().install_default();
|
|
}
|
|
|
|
// Initialize logging with TeeWriter (captures to ring buffer + stderr)
|
|
env_logger::Builder::from_env(
|
|
env_logger::Env::default().default_filter_or("info,zbus=warn,tracing=warn"),
|
|
)
|
|
.format_timestamp_millis()
|
|
.target(env_logger::Target::Pipe(Box::new(logging::TeeWriter)))
|
|
.init();
|
|
|
|
log::info!("QBZ starting...");
|
|
|
|
#[cfg(target_os = "linux")]
|
|
apply_linux_webkit_workarounds();
|
|
|
|
// Migrate data from old App ID if needed
|
|
match flatpak::migrate_app_id_data() {
|
|
Ok(true) => log::info!("App ID migration completed successfully"),
|
|
Ok(false) => log::debug!("No App ID migration needed"),
|
|
Err(e) => log::error!("App ID migration failed: {}", e),
|
|
}
|
|
|
|
// ── Phase 1: Device-level init (before login) ─────────────────────
|
|
// Read audio settings from flat path once for player initialization.
|
|
// The managed state starts empty and is populated after login.
|
|
let (saved_device, audio_settings) = config::audio_settings::AudioSettingsStore::new()
|
|
.ok()
|
|
.and_then(|store| {
|
|
store
|
|
.get_settings()
|
|
.ok()
|
|
.map(|settings| (settings.output_device.clone(), settings))
|
|
})
|
|
.unwrap_or_else(|| {
|
|
log::info!("No saved audio settings found, using defaults");
|
|
(None, config::audio_settings::AudioSettings::default())
|
|
});
|
|
|
|
if let Some(ref device) = saved_device {
|
|
log::info!("Initializing player with saved device: {}", device);
|
|
}
|
|
log::info!(
|
|
"Audio settings: exclusive_mode={}, dac_passthrough={}, sample_rate={:?}",
|
|
audio_settings.exclusive_mode,
|
|
audio_settings.dac_passthrough,
|
|
audio_settings.preferred_sample_rate
|
|
);
|
|
|
|
// Read tray settings for startup tray initialization.
|
|
// Prefer last active user-scoped settings, then fallback to global flat path.
|
|
let tray_settings = if let Some(last_uid) = user_data::UserDataPaths::load_last_user_id() {
|
|
let user_settings = dirs::data_dir()
|
|
.map(|d| d.join("qbz").join("users").join(last_uid.to_string()))
|
|
.and_then(|user_dir| {
|
|
config::tray_settings::TraySettingsStore::new_at(&user_dir)
|
|
.and_then(|store| store.get_settings())
|
|
.ok()
|
|
});
|
|
if let Some(settings) = user_settings {
|
|
log::info!("Loaded tray settings from last active user profile");
|
|
settings
|
|
} else {
|
|
config::tray_settings::TraySettingsStore::new()
|
|
.and_then(|store| store.get_settings())
|
|
.unwrap_or_default()
|
|
}
|
|
} else {
|
|
config::tray_settings::TraySettingsStore::new()
|
|
.and_then(|store| store.get_settings())
|
|
.unwrap_or_default()
|
|
};
|
|
log::info!(
|
|
"Tray settings: enable={}, minimize_to_tray={}, close_to_tray={}",
|
|
tray_settings.enable_tray,
|
|
tray_settings.minimize_to_tray,
|
|
tray_settings.close_to_tray
|
|
);
|
|
|
|
// Read window settings for decoration and size configuration before window creation.
|
|
let window_settings = config::window_settings::WindowSettingsStore::new()
|
|
.and_then(|store| store.get_settings())
|
|
.unwrap_or_default();
|
|
log::info!(
|
|
"Window settings: use_system_titlebar={}, size={}x{}, maximized={}",
|
|
window_settings.use_system_titlebar,
|
|
window_settings.window_width as u32,
|
|
window_settings.window_height as u32,
|
|
window_settings.is_maximized,
|
|
);
|
|
let use_system_titlebar = window_settings.use_system_titlebar;
|
|
let mut saved_win_width = window_settings.window_width;
|
|
let mut saved_win_height = window_settings.window_height;
|
|
let saved_win_maximized = window_settings.is_maximized;
|
|
|
|
// First-pass clamp: catch corrupt persisted sizes (>8K) before we even
|
|
// query monitors. The stricter fit-to-current-monitor clamp runs below
|
|
// inside setup() where the Tauri app handle is available.
|
|
{
|
|
const MAX_SANE_WIDTH: f64 = 7680.0; // 8K
|
|
const MAX_SANE_HEIGHT: f64 = 4320.0; // 8K
|
|
const FALLBACK_WIDTH: f64 = 1920.0;
|
|
const FALLBACK_HEIGHT: f64 = 1080.0;
|
|
if saved_win_width > MAX_SANE_WIDTH || saved_win_height > MAX_SANE_HEIGHT {
|
|
log::warn!(
|
|
"Window size {}x{} looks corrupt, falling back to {}x{}",
|
|
saved_win_width as u32,
|
|
saved_win_height as u32,
|
|
FALLBACK_WIDTH as u32,
|
|
FALLBACK_HEIGHT as u32
|
|
);
|
|
saved_win_width = FALLBACK_WIDTH;
|
|
saved_win_height = FALLBACK_HEIGHT;
|
|
}
|
|
}
|
|
|
|
// One-time cleanup: remove the KWin SSD window rule written by QBZ 1.1.14.
|
|
// That version wrote a kwinrulesrc entry forcing server-side decorations; it
|
|
// was removed in 1.1.15 due to a GTK3/WebKit heap corruption bug. We silently
|
|
// delete the stale rule so KWin stops applying SSD on existing installs.
|
|
// No qdbus6 reconfigure call here — KWin picks it up on next restart, and
|
|
// users affected can run `qdbus6 org.kde.KWin /KWin reconfigure` manually or
|
|
// just restart their session. This block does nothing if the rule is absent.
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
if let Some(path) = dirs::config_dir().map(|d| d.join("kwinrulesrc")) {
|
|
if let Ok(content) = std::fs::read_to_string(&path) {
|
|
if content.contains("Description=QBZ Native Title Bar") {
|
|
// Strip the [N] group that contains our rule
|
|
let mut out = String::with_capacity(content.len());
|
|
let mut skip = false;
|
|
for line in content.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
|
skip = false;
|
|
}
|
|
if trimmed == "Description=QBZ Native Title Bar" {
|
|
// Remove the header we already wrote + this section
|
|
// by trimming back to the previous newline
|
|
if let Some(pos) = out.rfind('[') {
|
|
out.truncate(pos);
|
|
}
|
|
skip = true;
|
|
continue;
|
|
}
|
|
if !skip {
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
}
|
|
}
|
|
if let Err(e) = std::fs::write(&path, out) {
|
|
log::warn!("[Cleanup] Failed to remove stale KWin rule: {}", e);
|
|
} else {
|
|
log::info!("[Cleanup] Removed stale KWin SSD rule from kwinrulesrc");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize casting state (Chromecast, DLNA) — device-level, not per-user
|
|
let cast_state = cast::CastState::new().expect("Failed to initialize Chromecast state");
|
|
let dlna_state = cast::DlnaState::new(cast_state.media_server.clone())
|
|
.expect("Failed to initialize DLNA state");
|
|
|
|
// Initialize API server state for remote control (device-level)
|
|
let api_server_state = api_server::ApiServerState::new();
|
|
|
|
// ── Phase 2: Per-user states (empty until activate_user_session) ──
|
|
let library_state = library::init_library_state_empty();
|
|
let offline_cache_state = offline_cache::OfflineCacheState::new_empty();
|
|
let lyrics_state = lyrics::LyricsState::new_empty();
|
|
let reco_state = reco_store::RecoState::new_empty();
|
|
let api_cache_state = api_cache::ApiCacheState::new_empty();
|
|
let session_store_state = session_store::SessionStoreState::new_empty();
|
|
let audio_settings_state = config::audio_settings::AudioSettingsState::new_empty();
|
|
let download_settings_state = config::download_settings::create_empty_download_settings_state();
|
|
let offline_state = offline::OfflineState::new_empty();
|
|
let playback_prefs_state = config::playback_preferences::PlaybackPreferencesState::new_empty();
|
|
let favorites_prefs_state =
|
|
config::favorites_preferences::FavoritesPreferencesState::new_empty();
|
|
let favorites_cache_state = config::favorites_cache::FavoritesCacheState::new_empty();
|
|
let tray_settings_state = config::tray_settings::TraySettingsState::new_empty();
|
|
let remote_control_settings_state =
|
|
config::remote_control_settings::RemoteControlSettingsState::new_empty();
|
|
let allowed_origins_state = config::remote_control_settings::AllowedOriginsState::new_empty();
|
|
// LegalSettings is GLOBAL (not per-user) - must be initialized at startup
|
|
// so ToS acceptance can be checked BEFORE attempting auto-login
|
|
let legal_settings_state = config::legal_settings::create_legal_settings_state()
|
|
.unwrap_or_else(|e| {
|
|
log::warn!(
|
|
"Failed to initialize legal settings: {}. Using empty state.",
|
|
e
|
|
);
|
|
config::legal_settings::create_empty_legal_settings_state()
|
|
});
|
|
let updates_state =
|
|
updates::UpdatesState::new_empty().expect("Failed to initialize empty updates state");
|
|
let subscription_state = config::create_empty_subscription_state();
|
|
let musicbrainz_state = musicbrainz::MusicBrainzSharedState::new_empty();
|
|
let artist_vectors_state = artist_vectors::ArtistVectorStoreState::new_empty();
|
|
let blacklist_state = artist_blacklist::BlacklistState::new_empty();
|
|
let listenbrainz_state = listenbrainz::ListenBrainzSharedState::new_empty();
|
|
|
|
// V2 integration states (using qbz-integrations crate)
|
|
let listenbrainz_v2_state = integrations_v2::ListenBrainzV2State::new();
|
|
let musicbrainz_v2_state = integrations_v2::MusicBrainzV2State::new();
|
|
let lastfm_v2_state = integrations_v2::LastFmV2State::new();
|
|
let image_cache_settings_state = config::ImageCacheSettingsState::new().unwrap_or_else(|e| {
|
|
log::warn!(
|
|
"Failed to initialize image cache settings: {}. Using empty state.",
|
|
e
|
|
);
|
|
config::ImageCacheSettingsState::new_empty()
|
|
});
|
|
let image_cache_state = image_cache::ImageCacheState::new().unwrap_or_else(|e| {
|
|
log::warn!(
|
|
"Failed to initialize image cache: {}. Using empty state.",
|
|
e
|
|
);
|
|
image_cache::ImageCacheState::new_empty()
|
|
});
|
|
let developer_settings_state = config::developer_settings::DeveloperSettingsState::new()
|
|
.unwrap_or_else(|e| {
|
|
log::warn!(
|
|
"Failed to initialize developer settings: {}. Using empty state.",
|
|
e
|
|
);
|
|
config::developer_settings::DeveloperSettingsState::new_empty()
|
|
});
|
|
let graphics_settings_state = config::graphics_settings::GraphicsSettingsState::new()
|
|
.unwrap_or_else(|e| {
|
|
log::warn!(
|
|
"Failed to initialize graphics settings: {}. Using empty state.",
|
|
e
|
|
);
|
|
config::graphics_settings::GraphicsSettingsState::new_empty()
|
|
});
|
|
let window_settings_state =
|
|
config::window_settings::WindowSettingsState::new().unwrap_or_else(|e| {
|
|
log::warn!(
|
|
"Failed to initialize window settings: {}. Using empty state.",
|
|
e
|
|
);
|
|
config::window_settings::WindowSettingsState::new_empty()
|
|
});
|
|
|
|
// Clone settings for use in closures
|
|
let enable_tray = tray_settings.enable_tray;
|
|
|
|
// Initialize per-user data paths (no user active yet until login)
|
|
let user_data_paths = user_data::UserDataPaths::new();
|
|
|
|
let builder = tauri::Builder::default()
|
|
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
|
// Second instance launched — bring existing window to front
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
let _ = window.show();
|
|
let _ = window.unminimize();
|
|
let _ = window.set_focus();
|
|
}
|
|
|
|
// Check if second instance was launched with a Qobuz link arg
|
|
for arg in &args {
|
|
if is_qobuz_link(arg) {
|
|
handle_qobuz_link(app, arg, false);
|
|
break;
|
|
}
|
|
}
|
|
}))
|
|
.plugin(tauri_plugin_deep_link::init())
|
|
.plugin(tauri_plugin_opener::init())
|
|
.plugin(tauri_plugin_os::init())
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.plugin(tauri_plugin_clipboard_manager::init());
|
|
|
|
#[cfg(feature = "updater")]
|
|
let builder = builder
|
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
.plugin(tauri_plugin_process::init());
|
|
|
|
builder
|
|
.manage(AppState::with_device_and_settings(
|
|
saved_device,
|
|
audio_settings,
|
|
))
|
|
.manage(core_bridge::CoreBridgeState::new())
|
|
.manage(commands_v2::OAuthCancelState::new())
|
|
.manage(qbzd_discovery::QbzdDiscoveryState::new())
|
|
.manage(runtime::RuntimeManagerState::new())
|
|
.manage(qconnect_service::QconnectServiceState::new())
|
|
.manage(user_data_paths)
|
|
.setup(move |app| {
|
|
log::info!(
|
|
"Creating main window (decorations={})",
|
|
use_system_titlebar
|
|
);
|
|
let main_window_transparent = should_use_main_window_transparency();
|
|
log::info!(
|
|
"Main window transparency: {} (override with QBZ_FORCE_TRANSPARENT_WINDOWS=1 or QBZ_FORCE_OPAQUE_WINDOWS=1)",
|
|
main_window_transparent
|
|
);
|
|
|
|
// Fit-to-monitor clamp: when the persisted size is larger than
|
|
// any available monitor the window would open partly off-screen
|
|
// and become unreachable until the user drags it back (or worse,
|
|
// when decorations are hidden there's no way to grab it). Query
|
|
// monitors at startup and shrink the saved size to 95% of the
|
|
// largest monitor's logical dimensions when it overflows.
|
|
{
|
|
let monitors = app.available_monitors().unwrap_or_default();
|
|
let max_logical = monitors
|
|
.iter()
|
|
.map(|m| {
|
|
let scale = m.scale_factor();
|
|
let size = m.size();
|
|
(size.width as f64 / scale, size.height as f64 / scale)
|
|
})
|
|
.fold((0.0_f64, 0.0_f64), |acc, (w, h)| {
|
|
(acc.0.max(w), acc.1.max(h))
|
|
});
|
|
let (mon_w, mon_h) = max_logical;
|
|
if mon_w > 0.0 && mon_h > 0.0 {
|
|
let fit_w = mon_w * 0.95;
|
|
let fit_h = mon_h * 0.95;
|
|
if saved_win_width > fit_w || saved_win_height > fit_h {
|
|
log::warn!(
|
|
"Persisted window size {}x{} exceeds available monitor ({}x{} logical); clamping to {}x{}",
|
|
saved_win_width as u32,
|
|
saved_win_height as u32,
|
|
mon_w as u32,
|
|
mon_h as u32,
|
|
fit_w as u32,
|
|
fit_h as u32
|
|
);
|
|
saved_win_width = saved_win_width.min(fit_w);
|
|
saved_win_height = saved_win_height.min(fit_h);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(unused_mut)]
|
|
let mut builder = tauri::WebviewWindowBuilder::new(
|
|
app,
|
|
"main",
|
|
tauri::WebviewUrl::App(std::path::PathBuf::from("index.html")),
|
|
)
|
|
.title("QBZ")
|
|
.inner_size(saved_win_width, saved_win_height)
|
|
.min_inner_size(800.0, 600.0)
|
|
.decorations(if cfg!(target_os = "macos") { true } else { use_system_titlebar })
|
|
.transparent(main_window_transparent)
|
|
.resizable(true)
|
|
.zoom_hotkeys_enabled(true);
|
|
|
|
// macOS: use overlay title bar style so content extends behind the title bar,
|
|
// and set background color to match the app theme
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
use tauri::TitleBarStyle;
|
|
use tauri::window::Color;
|
|
builder = builder
|
|
.title_bar_style(TitleBarStyle::Overlay)
|
|
.hidden_title(true)
|
|
.background_color(Color(0x0f, 0x0f, 0x0f, 0xff));
|
|
}
|
|
|
|
let main_window = builder.build()
|
|
.map_err(|e| {
|
|
log::error!("Failed to create main window: {}", e);
|
|
e
|
|
})?;
|
|
|
|
// Restore maximized state
|
|
if saved_win_maximized {
|
|
let _ = main_window.maximize();
|
|
}
|
|
|
|
// Persist window size across sessions.
|
|
// - Resize (non-maximized): saves width/height so last user size is remembered.
|
|
// - CloseRequested: saves the maximized flag.
|
|
{
|
|
let ws_state = app.state::<config::window_settings::WindowSettingsState>();
|
|
let store = std::sync::Arc::clone(&ws_state.store);
|
|
let win_for_events = main_window.clone();
|
|
main_window.on_window_event(move |event| {
|
|
match event {
|
|
tauri::WindowEvent::Resized(size) => {
|
|
if size.width > 0 && size.height > 0 {
|
|
let maximized = win_for_events.is_maximized().unwrap_or(false);
|
|
if !maximized {
|
|
// Convert physical pixels to logical pixels
|
|
// inner_size() expects logical, Resized gives physical
|
|
let scale = win_for_events.scale_factor().unwrap_or(1.0);
|
|
let logical_w = size.width as f64 / scale;
|
|
let logical_h = size.height as f64 / scale;
|
|
if let Ok(guard) = store.lock() {
|
|
if let Some(s) = guard.as_ref() {
|
|
let _ = s.set_window_size(logical_w, logical_h);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
tauri::WindowEvent::CloseRequested { .. } => {
|
|
let maximized = win_for_events.is_maximized().unwrap_or(false);
|
|
if let Ok(guard) = store.lock() {
|
|
if let Some(s) = guard.as_ref() {
|
|
let _ = s.set_is_maximized(maximized);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initialize system tray icon (only if enabled)
|
|
if enable_tray {
|
|
if let Err(e) = tray::init_tray(app.handle()) {
|
|
log::error!("Failed to initialize tray icon: {}", e);
|
|
}
|
|
} else {
|
|
log::info!("System tray icon disabled by user setting");
|
|
}
|
|
|
|
// Initialize media controls (MPRIS) now that we have an AppHandle
|
|
app.state::<AppState>()
|
|
.media_controls
|
|
.init(app.handle().clone());
|
|
|
|
// Initialize CoreBridge (new multi-crate architecture)
|
|
// Store V2 player state for event loop access
|
|
let v2_player_state: Arc<tokio::sync::RwLock<Option<qbz_player::SharedState>>> =
|
|
Arc::new(tokio::sync::RwLock::new(None));
|
|
let v2_player_state_setter = v2_player_state.clone();
|
|
{
|
|
let core_bridge_arc = app.state::<core_bridge::CoreBridgeState>().0.clone();
|
|
let adapter = tauri_adapter::TauriAdapter::new(app.handle().clone());
|
|
let v1_viz_tap = app.state::<AppState>().visualizer.get_tap();
|
|
let v2_viz_tap = qbz_audio::VisualizerTap {
|
|
ring_buffer: v1_viz_tap.ring_buffer.clone(),
|
|
enabled: v1_viz_tap.enabled.clone(),
|
|
sample_rate: v1_viz_tap.sample_rate.clone(),
|
|
};
|
|
tauri::async_runtime::spawn(async move {
|
|
let bridge = core_bridge::CoreBridge::new(adapter, Some(v2_viz_tap)).await;
|
|
match bridge {
|
|
Ok(b) => {
|
|
// Store V2 player state for event loop
|
|
let v2_state = b.player().state.clone();
|
|
*v2_player_state_setter.write().await = Some(v2_state);
|
|
*core_bridge_arc.write().await = Some(b);
|
|
log::info!(
|
|
"CoreBridge initialized successfully (V2 player state captured)"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to initialize CoreBridge: {}", e);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// NOTE: Visualizer FFT thread and Remote Control API server are started
|
|
// in activate_user_session (post-login), not here. They need per-user
|
|
// state to be initialized first.
|
|
|
|
// NOTE: Subscription purge check moved to activate_user_session
|
|
// (runs after login when per-user state is available)
|
|
|
|
// Check if app was launched with a Qobuz link argument.
|
|
// (first launch, not single-instance — that's handled by the plugin above)
|
|
// Note: on macOS, URLs arrive via Apple Events (deep-link handler below),
|
|
// not CLI args, so this path is only active on Linux/Windows.
|
|
{
|
|
let args: Vec<String> = std::env::args().collect();
|
|
for arg in &args[1..] {
|
|
if is_qobuz_link(arg) {
|
|
handle_qobuz_link(app.handle(), arg, true);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Register deep link handler for qobuzapp:// URLs.
|
|
// On macOS, URLs are delivered via Apple Events (not CLI args), so
|
|
// this is the only way to receive them. On Linux/Windows, the
|
|
// single-instance plugin forwards URL args to this handler too.
|
|
{
|
|
use tauri_plugin_deep_link::DeepLinkExt;
|
|
let deep_link_handle = app.handle().clone();
|
|
app.deep_link().on_open_url(move |event| {
|
|
for url in event.urls() {
|
|
let url_str = url.as_str();
|
|
log::debug!("[Deep Link] Received URL: {}", url_str.split('?').next().unwrap_or(url_str));
|
|
handle_qobuz_link(&deep_link_handle, url_str, false);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Start background task to emit playback events
|
|
let app_handle = app.handle().clone();
|
|
let legacy_player_state = app.state::<AppState>().player.state.clone();
|
|
|
|
tauri::async_runtime::spawn(async move {
|
|
let mut last_position: u64 = 0;
|
|
let mut last_is_playing: bool = false;
|
|
let mut last_track_id: u64 = 0;
|
|
|
|
loop {
|
|
// Check V2 player state first (takes priority if active)
|
|
// V2 player is accessed via async lock, but we only need a clone
|
|
let v2_state_opt: Option<qbz_player::SharedState> =
|
|
v2_player_state.read().await.clone();
|
|
|
|
// Determine which player is active (V2 takes priority if it has a track)
|
|
let (
|
|
is_playing,
|
|
track_id,
|
|
position,
|
|
duration,
|
|
volume,
|
|
sample_rate,
|
|
bit_depth,
|
|
normalization_gain,
|
|
gapless_ready,
|
|
gapless_next_track_id,
|
|
buffer_progress,
|
|
) = if let Some(ref v2_state) = v2_state_opt {
|
|
let v2_track_id = v2_state.current_track_id();
|
|
if v2_track_id != 0 {
|
|
// V2 player is active
|
|
(
|
|
v2_state.is_playing(),
|
|
v2_track_id,
|
|
v2_state.current_position(),
|
|
v2_state.duration(),
|
|
v2_state.volume(),
|
|
v2_state.get_sample_rate(),
|
|
v2_state.get_bit_depth(),
|
|
v2_state.get_normalization_gain(),
|
|
v2_state.is_gapless_ready(),
|
|
v2_state.get_gapless_next_track_id(),
|
|
v2_state.get_buffer_progress(),
|
|
)
|
|
} else {
|
|
// Fallback to legacy player
|
|
(
|
|
legacy_player_state.is_playing(),
|
|
legacy_player_state.current_track_id(),
|
|
legacy_player_state.current_position(),
|
|
legacy_player_state.duration(),
|
|
legacy_player_state.volume(),
|
|
legacy_player_state.get_sample_rate(),
|
|
legacy_player_state.get_bit_depth(),
|
|
legacy_player_state.get_normalization_gain(),
|
|
legacy_player_state.is_gapless_ready(),
|
|
legacy_player_state.get_gapless_next_track_id(),
|
|
None,
|
|
)
|
|
}
|
|
} else {
|
|
// V2 not initialized yet, use legacy
|
|
(
|
|
legacy_player_state.is_playing(),
|
|
legacy_player_state.current_track_id(),
|
|
legacy_player_state.current_position(),
|
|
legacy_player_state.duration(),
|
|
legacy_player_state.volume(),
|
|
legacy_player_state.get_sample_rate(),
|
|
legacy_player_state.get_bit_depth(),
|
|
legacy_player_state.get_normalization_gain(),
|
|
legacy_player_state.is_gapless_ready(),
|
|
legacy_player_state.get_gapless_next_track_id(),
|
|
None,
|
|
)
|
|
};
|
|
|
|
// Emit when:
|
|
// 1) normal in-track state changes/position updates, or
|
|
// 2) terminal transition to no-track (track_id becomes 0).
|
|
// Case (2) is required so frontend can run end-of-track fallback
|
|
// auto-advance paths reliably.
|
|
let track_cleared = track_id == 0 && last_track_id != 0;
|
|
let should_emit = (track_id != 0
|
|
&& (is_playing != last_is_playing
|
|
|| track_id != last_track_id
|
|
|| (is_playing && position != last_position)))
|
|
|| track_cleared;
|
|
|
|
let should_update_mpris = should_emit || (track_id == 0 && last_track_id != 0);
|
|
|
|
if should_emit {
|
|
// Get queue state for shuffle/repeat (still from legacy queue)
|
|
let queue_state = &app_handle.state::<AppState>().queue;
|
|
let shuffle = queue_state.is_shuffle();
|
|
let repeat = match queue_state.get_repeat() {
|
|
queue::RepeatMode::Off => "off",
|
|
queue::RepeatMode::All => "all",
|
|
queue::RepeatMode::One => "one",
|
|
};
|
|
// Use values collected from active player (V2 or legacy)
|
|
let event = player::PlaybackEvent {
|
|
is_playing,
|
|
position,
|
|
duration,
|
|
track_id,
|
|
volume,
|
|
sample_rate: if sample_rate > 0 {
|
|
Some(sample_rate)
|
|
} else {
|
|
None
|
|
},
|
|
bit_depth: if bit_depth > 0 { Some(bit_depth) } else { None },
|
|
shuffle: Some(shuffle),
|
|
repeat: Some(repeat.to_string()),
|
|
normalization_gain,
|
|
gapless_ready,
|
|
gapless_next_track_id,
|
|
buffer_progress,
|
|
};
|
|
let _ = app_handle.emit("playback:state", &event);
|
|
api_server::broadcast_playback_event(&app_handle, &event);
|
|
last_position = position;
|
|
last_is_playing = is_playing;
|
|
last_track_id = track_id;
|
|
}
|
|
|
|
if should_update_mpris {
|
|
let media_controls = &app_handle.state::<AppState>().media_controls;
|
|
if track_id == 0 {
|
|
media_controls.set_stopped();
|
|
} else {
|
|
media_controls.set_playback_with_progress(is_playing, position);
|
|
}
|
|
}
|
|
|
|
// Idle inhibit: prevent screen/suspend while playing (Linux only, via XDG Portal)
|
|
#[cfg(target_os = "linux")]
|
|
if is_playing != last_is_playing || track_cleared {
|
|
let inhibitor = &app_handle.state::<AppState>().idle_inhibitor;
|
|
if is_playing {
|
|
inhibitor.inhibit().await;
|
|
} else {
|
|
inhibitor.uninhibit().await;
|
|
}
|
|
}
|
|
|
|
// Adaptive polling:
|
|
// - fast (250ms) when playing - improves seekbar/lyrics sync
|
|
// - slow (1000ms) when paused/stopped with a track loaded
|
|
// - very slow (5000ms) when no track is loaded (idle)
|
|
let sleep_duration = if is_playing {
|
|
std::time::Duration::from_millis(250)
|
|
} else if track_id == 0 {
|
|
std::time::Duration::from_millis(5000)
|
|
} else {
|
|
std::time::Duration::from_millis(1000)
|
|
};
|
|
tokio::time::sleep(sleep_duration).await;
|
|
}
|
|
});
|
|
|
|
Ok(())
|
|
})
|
|
.on_window_event(move |window, event| {
|
|
// Only handle close-to-tray for the main window.
|
|
// Secondary windows (miniplayer, oauth) are managed elsewhere.
|
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
|
if window.label() != "main" {
|
|
return;
|
|
}
|
|
let close_to_tray = window
|
|
.app_handle()
|
|
.try_state::<config::tray_settings::TraySettingsState>()
|
|
.and_then(|state| state.get_settings().ok())
|
|
.map(|s| s.close_to_tray)
|
|
.unwrap_or(false);
|
|
if close_to_tray {
|
|
log::info!("Close to tray: hiding window instead of closing");
|
|
let _ = window.hide();
|
|
// Also hide the miniplayer if open
|
|
if let Some(mini) = window.app_handle().webview_windows().get("miniplayer") {
|
|
let _ = mini.hide();
|
|
}
|
|
api.prevent_close();
|
|
} else {
|
|
let open_windows: Vec<String> =
|
|
window.app_handle().webview_windows().keys().cloned().collect();
|
|
log::info!(
|
|
"App closing requested by user; open windows before exit: {:?}",
|
|
open_windows
|
|
);
|
|
|
|
// Cleanup cast devices on actual close
|
|
log::info!("App closing: cleaning up cast devices");
|
|
|
|
// Disconnect Chromecast if connected (sends message through channel)
|
|
if let Some(cast_state) = window.app_handle().try_state::<cast::CastState>() {
|
|
log::info!("Disconnecting Chromecast on app exit");
|
|
let _ = cast_state.chromecast.disconnect();
|
|
}
|
|
|
|
log::info!("DLNA connection will be cleaned up on drop");
|
|
|
|
// Allow Tauri's normal close flow for the main window.
|
|
// Forcing app_handle.exit(0) from CloseRequested can race
|
|
// WebKit process teardown on Linux (EGL/TLS destructors).
|
|
// Letting the window close naturally reduces shutdown races.
|
|
}
|
|
}
|
|
})
|
|
.manage(library_state)
|
|
.manage(cast_state)
|
|
.manage(dlna_state)
|
|
// .manage(airplay_state) // AirPlay DISABLED
|
|
.manage(offline_cache_state)
|
|
.manage(lyrics_state)
|
|
.manage(reco_state)
|
|
.manage(api_cache_state)
|
|
.manage(session_store_state)
|
|
.manage(audio_settings_state)
|
|
.manage(download_settings_state)
|
|
.manage(subscription_state)
|
|
.manage(offline_state)
|
|
.manage(playback_prefs_state)
|
|
.manage(favorites_prefs_state)
|
|
.manage(favorites_cache_state)
|
|
.manage(tray_settings_state)
|
|
.manage(remote_control_settings_state)
|
|
.manage(allowed_origins_state)
|
|
.manage(api_server_state)
|
|
.manage(legal_settings_state)
|
|
.manage(updates_state)
|
|
.manage(musicbrainz_state)
|
|
.manage(listenbrainz_state)
|
|
.manage(artist_vectors_state)
|
|
.manage(blacklist_state)
|
|
.manage(developer_settings_state)
|
|
.manage(graphics_settings_state)
|
|
.manage(window_settings_state)
|
|
.manage(image_cache_settings_state)
|
|
.manage(image_cache_state)
|
|
.manage(pdf_viewer::BookletState::new())
|
|
// V2 integration states (qbz-integrations crate)
|
|
.manage(listenbrainz_v2_state)
|
|
.manage(musicbrainz_v2_state)
|
|
.manage(lastfm_v2_state)
|
|
.invoke_handler(tauri::generate_handler![
|
|
commands_v2::runtime_get_status,
|
|
commands_v2::runtime_bootstrap,
|
|
qconnect_service::v2_qconnect_connect,
|
|
qconnect_service::v2_qconnect_disconnect,
|
|
qconnect_service::v2_qconnect_status,
|
|
qconnect_service::v2_qconnect_send_command,
|
|
qconnect_service::v2_qconnect_evaluate_queue_admission,
|
|
qconnect_service::v2_qconnect_send_command_with_admission,
|
|
qconnect_service::v2_qconnect_join_session,
|
|
qconnect_service::v2_qconnect_set_player_state,
|
|
qconnect_service::v2_qconnect_toggle_play_if_remote,
|
|
qconnect_service::v2_qconnect_play_track_if_remote,
|
|
qconnect_service::v2_qconnect_skip_next_if_remote,
|
|
qconnect_service::v2_qconnect_skip_previous_if_remote,
|
|
qconnect_service::v2_qconnect_set_volume_if_remote,
|
|
qconnect_service::v2_qconnect_mute_if_remote,
|
|
qconnect_service::v2_qconnect_stop_if_remote,
|
|
qconnect_service::v2_qconnect_toggle_shuffle_if_remote,
|
|
qconnect_service::v2_qconnect_cycle_repeat_if_remote,
|
|
qconnect_service::v2_qconnect_set_autoplay_mode_if_remote,
|
|
qconnect_service::v2_qconnect_autoplay_load_tracks_if_remote,
|
|
qconnect_service::v2_qconnect_set_active_renderer,
|
|
qconnect_service::v2_qconnect_set_volume,
|
|
qconnect_service::v2_qconnect_set_loop_mode,
|
|
qconnect_service::v2_qconnect_mute_volume,
|
|
qconnect_service::v2_qconnect_set_max_audio_quality,
|
|
qconnect_service::v2_qconnect_ask_for_renderer_state,
|
|
qconnect_service::v2_qconnect_queue_snapshot,
|
|
qconnect_service::v2_qconnect_renderer_snapshot,
|
|
qconnect_service::v2_qconnect_session_snapshot,
|
|
qconnect_service::v2_qconnect_report_playback_state,
|
|
qconnect_service::v2_qconnect_report_volume,
|
|
qconnect_service::v2_qconnect_get_device_name,
|
|
qconnect_service::v2_qconnect_set_device_name,
|
|
qconnect_service::v2_get_hostname,
|
|
commands_v2::v2_is_logged_in,
|
|
commands_v2::v2_login,
|
|
commands_v2::v2_logout,
|
|
commands_v2::v2_activate_offline_session,
|
|
commands_v2::v2_init_client,
|
|
commands_v2::v2_auto_login,
|
|
commands_v2::v2_manual_login,
|
|
commands_v2::v2_start_oauth_login,
|
|
commands_v2::v2_start_system_browser_oauth,
|
|
commands_v2::v2_cancel_oauth_login,
|
|
commands_v2::v2_cancel_system_browser_oauth,
|
|
qbzd_discovery::v2_qbzd_start_discovery,
|
|
qbzd_discovery::v2_qbzd_stop_discovery,
|
|
qbzd_discovery::v2_qbzd_get_devices,
|
|
commands_v2::v2_get_user_info,
|
|
commands_v2::v2_save_credentials,
|
|
commands_v2::v2_clear_saved_credentials,
|
|
// Temporary compatibility commands still invoked by frontend during migration
|
|
offline_cache::commands::check_album_fully_cached,
|
|
network::commands::check_network_path,
|
|
offline::commands::create_pending_playlist,
|
|
offline::commands::get_pending_playlist_count,
|
|
offline::commands::queue_scrobble,
|
|
offline::commands::get_queued_scrobbles,
|
|
offline::commands::get_queued_scrobble_count,
|
|
offline::commands::cleanup_sent_scrobbles,
|
|
library::commands::discogs_has_credentials,
|
|
library::commands::discogs_search_artwork,
|
|
library::commands::discogs_download_artwork,
|
|
library::commands::library_update_folder_settings,
|
|
library::commands::get_track_by_path,
|
|
commands::search::get_artist_basic,
|
|
commands::search::get_artist_detail,
|
|
commands::search::get_artist_tracks,
|
|
commands::search::get_artist_albums,
|
|
commands::search::get_releases_grid,
|
|
commands::playback::get_pipewire_sinks,
|
|
commands::playback::get_audio_output_status,
|
|
flatpak::get_flatpak_help_text,
|
|
config::legal_settings::get_qobuz_tos_accepted,
|
|
updates::has_flatpak_welcome_been_shown,
|
|
updates::has_snap_welcome_been_shown,
|
|
lyrics::commands::lyrics_get,
|
|
config::favorites_cache::is_track_favorite,
|
|
commands_v2::v2_set_api_locale,
|
|
commands_v2::v2_set_use_system_titlebar,
|
|
commands_v2::v2_set_enable_tray,
|
|
commands_v2::v2_set_minimize_to_tray,
|
|
commands_v2::v2_set_close_to_tray,
|
|
commands_v2::v2_get_tray_settings,
|
|
commands_v2::v2_set_autoplay_mode,
|
|
commands_v2::v2_set_show_context_icon,
|
|
commands_v2::v2_set_persist_session,
|
|
commands_v2::v2_get_playback_preferences,
|
|
commands_v2::v2_get_favorites_preferences,
|
|
commands_v2::v2_save_favorites_preferences,
|
|
commands_v2::v2_get_cache_stats,
|
|
commands_v2::v2_get_available_backends,
|
|
commands_v2::v2_get_devices_for_backend,
|
|
commands_v2::v2_get_hardware_audio_status,
|
|
commands_v2::v2_get_default_device_name,
|
|
commands_v2::v2_query_dac_capabilities,
|
|
commands_v2::v2_get_alsa_plugins,
|
|
commands_v2::v2_plex_ping,
|
|
commands_v2::v2_plex_get_music_sections,
|
|
commands_v2::v2_plex_get_section_tracks,
|
|
commands_v2::v2_plex_get_track_metadata,
|
|
commands_v2::v2_plex_auth_pin_start,
|
|
commands_v2::v2_plex_auth_pin_check,
|
|
commands_v2::v2_plex_play_track,
|
|
commands_v2::v2_set_visualizer_enabled,
|
|
commands_v2::v2_get_developer_settings,
|
|
commands_v2::v2_get_runtime_diagnostics,
|
|
commands_v2::v2_set_developer_force_dmabuf,
|
|
commands_v2::v2_get_graphics_settings,
|
|
commands_v2::v2_get_graphics_startup_status,
|
|
commands_v2::v2_set_hardware_acceleration,
|
|
commands_v2::v2_set_gdk_scale,
|
|
commands_v2::v2_set_gdk_dpi_scale,
|
|
commands_v2::v2_set_gsk_renderer,
|
|
commands_v2::v2_clear_cache,
|
|
commands_v2::v2_clear_artist_cache,
|
|
commands_v2::v2_get_vector_store_stats,
|
|
commands_v2::v2_get_playlist_suggestions,
|
|
commands_v2::v2_clear_vector_store,
|
|
commands_v2::v2_add_to_artist_blacklist,
|
|
commands_v2::v2_remove_from_artist_blacklist,
|
|
commands_v2::v2_set_blacklist_enabled,
|
|
commands_v2::v2_get_artist_blacklist,
|
|
commands_v2::v2_get_blacklist_settings,
|
|
commands_v2::v2_clear_artist_blacklist,
|
|
commands_v2::v2_plex_open_auth_url,
|
|
commands_v2::v2_plex_cache_get_sections,
|
|
commands_v2::v2_plex_cache_save_sections,
|
|
commands_v2::v2_plex_cache_save_tracks,
|
|
commands_v2::v2_plex_cache_get_tracks,
|
|
commands_v2::v2_plex_cache_get_albums,
|
|
commands_v2::v2_plex_cache_get_album_tracks,
|
|
commands_v2::v2_plex_cache_search_tracks,
|
|
commands_v2::v2_plex_cache_update_track_quality,
|
|
commands_v2::v2_plex_cache_get_tracks_needing_hydration,
|
|
commands_v2::v2_plex_cache_clear,
|
|
commands_v2::v2_cast_start_discovery,
|
|
commands_v2::v2_cast_stop_discovery,
|
|
commands_v2::v2_cast_get_devices,
|
|
commands_v2::v2_cast_connect,
|
|
commands_v2::v2_cast_disconnect,
|
|
commands_v2::v2_cast_play_track,
|
|
commands_v2::v2_cast_play,
|
|
commands_v2::v2_cast_pause,
|
|
commands_v2::v2_cast_stop,
|
|
commands_v2::v2_cast_seek,
|
|
commands_v2::v2_cast_get_position,
|
|
commands_v2::v2_cast_set_volume,
|
|
commands_v2::v2_dlna_start_discovery,
|
|
commands_v2::v2_dlna_stop_discovery,
|
|
commands_v2::v2_dlna_get_devices,
|
|
commands_v2::v2_dlna_connect,
|
|
commands_v2::v2_dlna_disconnect,
|
|
commands_v2::v2_dlna_play_track,
|
|
commands_v2::v2_dlna_play,
|
|
commands_v2::v2_dlna_pause,
|
|
commands_v2::v2_dlna_stop,
|
|
commands_v2::v2_dlna_seek,
|
|
commands_v2::v2_dlna_get_position,
|
|
commands_v2::v2_dlna_set_volume,
|
|
commands_v2::v2_airplay_start_discovery,
|
|
commands_v2::v2_airplay_stop_discovery,
|
|
commands_v2::v2_airplay_get_devices,
|
|
commands_v2::v2_airplay_connect,
|
|
commands_v2::v2_airplay_disconnect,
|
|
commands_v2::v2_airplay_load_media,
|
|
commands_v2::v2_airplay_play,
|
|
commands_v2::v2_airplay_pause,
|
|
commands_v2::v2_airplay_stop,
|
|
commands_v2::v2_airplay_set_volume,
|
|
commands_v2::v2_clear_offline_cache,
|
|
commands_v2::v2_library_remove_folder,
|
|
commands_v2::v2_library_check_folder_accessible,
|
|
commands_v2::v2_library_clear_artwork_cache,
|
|
commands_v2::v2_library_clear_thumbnails_cache,
|
|
commands_v2::v2_library_get_thumbnail,
|
|
commands_v2::v2_library_get_thumbnails_cache_size,
|
|
commands_v2::v2_library_get_scan_progress,
|
|
commands_v2::v2_library_get_tracks_by_ids,
|
|
commands_v2::v2_library_play_track,
|
|
commands_v2::v2_playlist_set_sort,
|
|
commands_v2::v2_playlist_set_artwork,
|
|
commands_v2::v2_playlist_get_all_settings,
|
|
commands_v2::v2_playlist_get_favorites,
|
|
commands_v2::v2_playlist_get_local_tracks_with_position,
|
|
commands_v2::v2_playlist_get_settings,
|
|
commands_v2::v2_playlist_get_stats,
|
|
commands_v2::v2_playlist_increment_play_count,
|
|
commands_v2::v2_playlist_get_all_stats,
|
|
commands_v2::v2_playlist_get_all_local_track_counts,
|
|
commands_v2::v2_playlist_add_local_track,
|
|
commands_v2::v2_playlist_remove_local_track,
|
|
commands_v2::v2_playlist_set_hidden,
|
|
commands_v2::v2_playlist_set_favorite,
|
|
commands_v2::v2_playlist_reorder,
|
|
commands_v2::v2_playlist_init_custom_order,
|
|
commands_v2::v2_playlist_set_custom_order,
|
|
commands_v2::v2_playlist_move_track,
|
|
commands_v2::v2_playlist_get_custom_order,
|
|
commands_v2::v2_playlist_has_custom_order,
|
|
commands_v2::v2_playlist_get_tracks_with_local_copies,
|
|
commands_v2::v2_library_set_album_artwork,
|
|
commands_v2::v2_library_set_album_hidden,
|
|
commands_v2::v2_create_artist_radio,
|
|
commands_v2::v2_create_track_radio,
|
|
commands_v2::v2_create_album_radio,
|
|
commands_v2::v2_create_qobuz_artist_radio,
|
|
commands_v2::v2_create_qobuz_track_radio,
|
|
commands_v2::v2_delete_playlist_folder,
|
|
commands_v2::v2_reorder_playlist_folders,
|
|
commands_v2::v2_move_playlist_to_folder,
|
|
commands_v2::v2_get_playlist_folders,
|
|
commands_v2::v2_create_playlist_folder,
|
|
commands_v2::v2_update_playlist_folder,
|
|
commands_v2::v2_lyrics_clear_cache,
|
|
commands_v2::v2_lyrics_get_cache_stats,
|
|
commands_v2::v2_musicbrainz_get_cache_stats,
|
|
commands_v2::v2_musicbrainz_clear_cache,
|
|
commands_v2::v2_set_show_partial_playlists,
|
|
commands_v2::v2_set_allow_cast_while_offline,
|
|
commands_v2::v2_set_allow_immediate_scrobbling,
|
|
commands_v2::v2_set_allow_accumulated_scrobbling,
|
|
commands_v2::v2_set_show_network_folders_in_manual_offline,
|
|
commands_v2::v2_get_offline_status,
|
|
commands_v2::v2_get_offline_settings,
|
|
commands_v2::v2_set_manual_offline,
|
|
commands_v2::v2_check_network,
|
|
commands_v2::v2_add_tracks_to_pending_playlist,
|
|
commands_v2::v2_get_pending_playlists,
|
|
commands_v2::v2_update_pending_playlist_qobuz_id,
|
|
commands_v2::v2_mark_pending_playlist_synced,
|
|
commands_v2::v2_delete_pending_playlist,
|
|
commands_v2::v2_mark_scrobbles_sent,
|
|
commands_v2::v2_remove_cached_track,
|
|
commands_v2::v2_get_cached_tracks,
|
|
commands_v2::v2_get_offline_cache_stats,
|
|
commands_v2::v2_set_offline_cache_limit,
|
|
commands_v2::v2_open_offline_cache_folder,
|
|
commands_v2::v2_open_album_folder,
|
|
commands_v2::v2_open_track_folder,
|
|
commands_v2::v2_lastfm_open_auth_url,
|
|
commands_v2::v2_lastfm_set_credentials,
|
|
commands_v2::v2_lastfm_has_embedded_credentials,
|
|
commands_v2::v2_remote_control_get_status,
|
|
commands_v2::v2_remote_control_set_enabled,
|
|
commands_v2::v2_remote_control_set_port,
|
|
commands_v2::v2_remote_control_set_secure,
|
|
commands_v2::v2_remote_control_regenerate_token,
|
|
commands_v2::v2_remote_control_get_pairing_qr,
|
|
commands_v2::v2_is_running_in_flatpak,
|
|
commands_v2::v2_is_running_in_snap,
|
|
commands_v2::v2_mark_snap_welcome_shown,
|
|
commands_v2::v2_detect_legacy_cached_files,
|
|
commands_v2::v2_reco_log_event,
|
|
commands_v2::v2_reco_train_scores,
|
|
commands_v2::v2_reco_get_home,
|
|
commands_v2::v2_reco_get_home_ml,
|
|
commands_v2::v2_reco_get_home_resolved,
|
|
commands_v2::v2_get_album_suggestions,
|
|
commands_v2::v2_reco_get_forgotten_favorites,
|
|
commands_v2::v2_reco_get_top_genres,
|
|
commands_v2::v2_library_get_cache_stats,
|
|
commands_v2::v2_library_get_stats,
|
|
commands_v2::v2_library_get_albums,
|
|
commands_v2::v2_library_get_folders,
|
|
commands_v2::v2_library_get_folders_with_metadata,
|
|
commands_v2::v2_library_add_folder,
|
|
commands_v2::v2_library_cleanup_missing_files,
|
|
commands_v2::v2_library_fetch_missing_artwork,
|
|
commands_v2::v2_library_get_artists,
|
|
commands_v2::v2_library_search,
|
|
commands_v2::v2_library_get_album_tracks,
|
|
commands_v2::v2_library_update_folder_path,
|
|
commands_v2::v2_library_cache_artist_image,
|
|
commands_v2::v2_library_set_custom_artist_image,
|
|
commands_v2::v2_library_remove_custom_artist_image,
|
|
commands_v2::v2_library_get_artist_image,
|
|
commands_v2::v2_library_get_all_custom_artist_images,
|
|
commands_v2::v2_library_set_custom_album_cover,
|
|
commands_v2::v2_library_remove_custom_album_cover,
|
|
commands_v2::v2_library_get_all_custom_album_covers,
|
|
commands_v2::v2_save_image_url_to_file,
|
|
commands_v2::v2_show_track_notification,
|
|
commands_v2::v2_subscribe_playlist,
|
|
commands_v2::v2_qobuz_subscribe_playlist,
|
|
commands_v2::v2_qobuz_unsubscribe_playlist,
|
|
commands_v2::v2_cache_track_for_offline,
|
|
commands_v2::v2_cache_tracks_batch_for_offline,
|
|
commands_v2::v2_start_legacy_migration,
|
|
commands_v2::v2_library_scan,
|
|
commands_v2::v2_library_stop_scan,
|
|
commands_v2::v2_library_scan_folder,
|
|
commands_v2::v2_library_clear,
|
|
commands_v2::v2_library_update_album_metadata,
|
|
commands_v2::v2_library_write_album_metadata_to_files,
|
|
commands_v2::v2_library_refresh_album_metadata_from_files,
|
|
commands_v2::v2_factory_reset,
|
|
commands_v2::v2_set_qobuz_tos_accepted,
|
|
commands_v2::v2_get_update_preferences,
|
|
commands_v2::v2_get_current_version,
|
|
commands_v2::v2_check_for_updates,
|
|
commands_v2::v2_fetch_release_for_version,
|
|
commands_v2::v2_set_update_check_on_launch,
|
|
commands_v2::v2_set_show_whats_new_on_launch,
|
|
commands_v2::v2_acknowledge_release,
|
|
commands_v2::v2_ignore_release,
|
|
commands_v2::v2_has_whats_new_been_shown,
|
|
commands_v2::v2_mark_whats_new_shown,
|
|
commands_v2::v2_mark_flatpak_welcome_shown,
|
|
commands_v2::v2_is_auto_update_eligible,
|
|
commands_v2::v2_get_backend_logs,
|
|
commands_v2::v2_upload_logs_to_paste,
|
|
commands_v2::v2_get_download_settings,
|
|
commands_v2::v2_set_show_downloads_in_library,
|
|
commands_v2::v2_get_device_sample_rate_limit,
|
|
commands_v2::v2_set_device_sample_rate_limit,
|
|
commands_v2::v2_set_force_x11,
|
|
commands_v2::v2_restart_app,
|
|
commands_v2::v2_get_queue_state,
|
|
commands_v2::v2_get_all_queue_tracks,
|
|
commands_v2::v2_get_current_queue_track,
|
|
commands_v2::v2_set_repeat_mode,
|
|
commands_v2::v2_toggle_shuffle,
|
|
commands_v2::v2_set_shuffle,
|
|
commands_v2::v2_clear_queue,
|
|
commands_v2::v2_add_to_queue,
|
|
commands_v2::v2_add_to_queue_next,
|
|
commands_v2::v2_bulk_add_to_queue,
|
|
commands_v2::v2_bulk_add_to_queue_next,
|
|
commands_v2::v2_set_queue,
|
|
commands_v2::v2_remove_from_queue,
|
|
commands_v2::v2_remove_upcoming_track,
|
|
commands_v2::v2_next_track,
|
|
commands_v2::v2_previous_track,
|
|
commands_v2::v2_play_queue_index,
|
|
commands_v2::v2_move_queue_track,
|
|
commands_v2::v2_add_tracks_to_queue,
|
|
commands_v2::v2_add_tracks_to_queue_next,
|
|
commands_v2::v2_search_albums,
|
|
commands_v2::v2_search_tracks,
|
|
commands_v2::v2_search_artists,
|
|
commands_v2::v2_search_all,
|
|
commands_v2::v2_get_album,
|
|
commands_v2::v2_get_track,
|
|
commands_v2::v2_get_artist,
|
|
commands_v2::v2_get_favorites,
|
|
commands_v2::v2_add_favorite,
|
|
commands_v2::v2_remove_favorite,
|
|
commands_v2::v2_get_user_playlists,
|
|
commands_v2::v2_get_playlist,
|
|
commands_v2::v2_playlist_import_preview,
|
|
commands_v2::v2_playlist_import_execute,
|
|
commands_v2::v2_get_playlist_track_ids,
|
|
commands_v2::v2_check_playlist_duplicates,
|
|
commands_v2::v2_add_tracks_to_playlist,
|
|
commands_v2::v2_remove_tracks_from_playlist,
|
|
commands_v2::v2_create_playlist,
|
|
commands_v2::v2_delete_playlist,
|
|
commands_v2::v2_update_playlist,
|
|
commands_v2::v2_search_playlists,
|
|
commands_v2::v2_get_tracks_batch,
|
|
commands_v2::v2_get_genres,
|
|
commands_v2::v2_get_discover_index,
|
|
commands_v2::v2_get_discover_playlists,
|
|
commands_v2::v2_get_playlist_tags,
|
|
commands_v2::v2_get_discover_albums,
|
|
commands_v2::v2_get_featured_albums,
|
|
commands_v2::v2_get_artist_page,
|
|
commands_v2::v2_get_similar_artists,
|
|
commands_v2::v2_get_artist_with_albums,
|
|
commands_v2::v2_get_label,
|
|
commands_v2::v2_get_label_page,
|
|
commands_v2::v2_get_label_explore,
|
|
commands_v2::v2_pause_playback,
|
|
commands_v2::v2_resume_playback,
|
|
commands_v2::v2_stop_playback,
|
|
commands_v2::v2_seek,
|
|
commands_v2::v2_set_volume,
|
|
commands_v2::v2_get_playback_state,
|
|
commands_v2::v2_play_track,
|
|
commands_v2::v2_set_media_metadata,
|
|
commands_v2::v2_play_next_gapless,
|
|
commands_v2::v2_prefetch_track,
|
|
commands_v2::v2_reinit_audio_device,
|
|
commands_v2::v2_get_audio_settings,
|
|
commands_v2::v2_set_audio_output_device,
|
|
commands_v2::v2_set_audio_exclusive_mode,
|
|
commands_v2::v2_set_audio_dac_passthrough,
|
|
commands_v2::v2_set_audio_pw_force_bitperfect,
|
|
commands_v2::v2_set_sync_audio_on_startup,
|
|
commands_v2::v2_get_quality_fallback_behavior,
|
|
commands_v2::v2_set_quality_fallback_behavior,
|
|
commands_v2::v2_set_audio_sample_rate,
|
|
commands_v2::v2_set_audio_backend_type,
|
|
commands_v2::v2_set_audio_alsa_plugin,
|
|
commands_v2::v2_set_audio_gapless_enabled,
|
|
commands_v2::v2_set_audio_allow_quality_fallback,
|
|
commands_v2::v2_set_audio_skip_sink_switch,
|
|
commands_v2::v2_set_audio_normalization_enabled,
|
|
commands_v2::v2_set_audio_normalization_target,
|
|
commands_v2::v2_set_audio_device_max_sample_rate,
|
|
commands_v2::v2_set_audio_limit_quality_to_device,
|
|
commands_v2::v2_set_audio_streaming_only,
|
|
commands_v2::v2_reset_audio_settings,
|
|
commands_v2::v2_set_audio_stream_first_track,
|
|
commands_v2::v2_set_audio_stream_buffer_seconds,
|
|
commands_v2::v2_set_audio_alsa_hardware_volume,
|
|
commands_v2::v2_listenbrainz_get_status,
|
|
commands_v2::v2_listenbrainz_is_enabled,
|
|
commands_v2::v2_listenbrainz_set_enabled,
|
|
commands_v2::v2_listenbrainz_connect,
|
|
commands_v2::v2_listenbrainz_disconnect,
|
|
commands_v2::v2_listenbrainz_now_playing,
|
|
commands_v2::v2_listenbrainz_scrobble,
|
|
commands_v2::v2_musicbrainz_is_enabled,
|
|
commands_v2::v2_musicbrainz_set_enabled,
|
|
commands_v2::v2_musicbrainz_resolve_track,
|
|
commands_v2::v2_musicbrainz_resolve_artist,
|
|
commands_v2::v2_resolve_musician,
|
|
commands_v2::v2_get_musician_appearances,
|
|
commands_v2::v2_remote_metadata_search,
|
|
commands_v2::v2_remote_metadata_get_album,
|
|
commands_v2::v2_musicbrainz_get_artist_relationships,
|
|
commands_v2::v2_musicbrainz_get_artist_metadata,
|
|
commands_v2::v2_discover_artists_by_location,
|
|
commands_v2::v2_get_discovery_artists,
|
|
commands_v2::v2_dismiss_discovery_artist,
|
|
commands_v2::v2_lastfm_get_auth_url,
|
|
commands_v2::v2_lastfm_complete_auth,
|
|
commands_v2::v2_lastfm_is_authenticated,
|
|
commands_v2::v2_lastfm_disconnect,
|
|
commands_v2::v2_lastfm_now_playing,
|
|
commands_v2::v2_lastfm_scrobble,
|
|
commands_v2::v2_lastfm_set_session,
|
|
commands_v2::v2_listenbrainz_queue_listen,
|
|
commands_v2::v2_listenbrainz_flush_queue,
|
|
commands_v2::v2_get_playback_context,
|
|
commands_v2::v2_set_playback_context,
|
|
commands_v2::v2_clear_playback_context,
|
|
commands_v2::v2_has_playback_context,
|
|
commands_v2::v2_save_session_state,
|
|
commands_v2::v2_load_session_state,
|
|
commands_v2::v2_save_session_position,
|
|
commands_v2::v2_save_session_volume,
|
|
commands_v2::v2_save_session_playback_mode,
|
|
commands_v2::v2_clear_session,
|
|
commands_v2::v2_get_cached_favorite_tracks,
|
|
commands_v2::v2_sync_cached_favorite_tracks,
|
|
commands_v2::v2_cache_favorite_track,
|
|
commands_v2::v2_uncache_favorite_track,
|
|
commands_v2::v2_bulk_add_favorites,
|
|
commands_v2::v2_clear_favorites_cache,
|
|
commands_v2::v2_get_cached_favorite_albums,
|
|
commands_v2::v2_sync_cached_favorite_albums,
|
|
commands_v2::v2_cache_favorite_album,
|
|
commands_v2::v2_uncache_favorite_album,
|
|
commands_v2::v2_get_cached_favorite_artists,
|
|
commands_v2::v2_sync_cached_favorite_artists,
|
|
commands_v2::v2_cache_favorite_artist,
|
|
commands_v2::v2_uncache_favorite_artist,
|
|
commands_v2::v2_share_track_songlink,
|
|
commands_v2::v2_share_album_songlink,
|
|
commands_v2::v2_library_backfill_downloads,
|
|
commands_v2::v2_lyrics_get,
|
|
commands_v2::v2_create_pending_playlist,
|
|
commands_v2::v2_get_pending_playlist_count,
|
|
commands_v2::v2_queue_scrobble,
|
|
commands_v2::v2_get_queued_scrobbles,
|
|
commands_v2::v2_get_queued_scrobble_count,
|
|
commands_v2::v2_cleanup_sent_scrobbles,
|
|
commands_v2::v2_get_track_by_path,
|
|
commands_v2::v2_check_network_path,
|
|
commands_v2::v2_library_update_folder_settings,
|
|
commands_v2::v2_discogs_has_credentials,
|
|
commands_v2::v2_discogs_search_artwork,
|
|
commands_v2::v2_discogs_download_artwork,
|
|
commands_v2::v2_check_album_fully_cached,
|
|
commands_v2::v2_check_albums_fully_cached_batch,
|
|
commands_v2::v2_purchases_get_all,
|
|
commands_v2::v2_purchases_get_ids,
|
|
commands_v2::v2_purchases_get_by_type,
|
|
commands_v2::v2_purchases_search,
|
|
commands_v2::v2_purchases_get_album,
|
|
commands_v2::v2_purchases_get_formats,
|
|
commands_v2::v2_purchases_download_album,
|
|
commands_v2::v2_purchases_download_track,
|
|
commands_v2::v2_purchases_mark_downloaded,
|
|
commands_v2::v2_purchases_remove_downloaded,
|
|
commands_v2::v2_purchases_get_downloaded_track_ids,
|
|
commands_v2::v2_dynamic_suggest,
|
|
commands_v2::v2_dynamic_suggest_raw,
|
|
commands_v2::v2_resolve_music_link,
|
|
commands_v2::v2_resolve_qobuz_link,
|
|
commands_v2::v2_get_qobuz_track_url,
|
|
commands_v2::v2_check_qobuzapp_handler,
|
|
commands_v2::v2_register_qobuzapp_handler,
|
|
commands_v2::v2_deregister_qobuzapp_handler,
|
|
// Auto-theme
|
|
commands_v2::v2_detect_desktop_environment,
|
|
commands_v2::v2_get_system_wallpaper,
|
|
commands_v2::v2_get_system_accent_color,
|
|
commands_v2::v2_generate_theme_from_image,
|
|
commands_v2::v2_generate_theme_from_wallpaper,
|
|
commands_v2::v2_generate_theme_from_system_colors,
|
|
commands_v2::v2_get_system_color_scheme,
|
|
commands_v2::v2_extract_palette,
|
|
commands_v2::v2_fetch_url_bytes,
|
|
// PDF booklet viewer (MuPDF backend when "booklet" feature is enabled, stubs otherwise)
|
|
pdf_viewer::v2_booklet_open,
|
|
pdf_viewer::v2_booklet_render_page,
|
|
pdf_viewer::v2_booklet_save,
|
|
pdf_viewer::v2_booklet_close,
|
|
// Image cache
|
|
commands_v2::v2_get_cached_image,
|
|
commands_v2::v2_get_image_cache_settings,
|
|
commands_v2::v2_set_image_cache_enabled,
|
|
commands_v2::v2_set_image_cache_max_size,
|
|
commands_v2::v2_get_image_cache_stats,
|
|
commands_v2::v2_clear_image_cache,
|
|
])
|
|
.build(tauri::generate_context!())
|
|
.expect("error while building tauri application")
|
|
.run(|app_handle, event| {
|
|
match event {
|
|
tauri::RunEvent::WindowEvent { label, event, .. } => {
|
|
if label == "main" {
|
|
if let tauri::WindowEvent::Destroyed = event {
|
|
let close_to_tray = app_handle
|
|
.try_state::<config::tray_settings::TraySettingsState>()
|
|
.and_then(|state| state.get_settings().ok())
|
|
.map(|s| s.close_to_tray)
|
|
.unwrap_or(false);
|
|
|
|
if !close_to_tray {
|
|
log::info!(
|
|
"RunEvent::WindowEvent::Destroyed(main) with close_to_tray=false; exiting app"
|
|
);
|
|
app_handle.exit(0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
tauri::RunEvent::Exit => {
|
|
let open_windows: Vec<String> =
|
|
app_handle.webview_windows().keys().cloned().collect();
|
|
log::info!("RunEvent::Exit — windows visible to app: {:?}", open_windows);
|
|
|
|
// Graceful shutdown: stop audio and visualizer BEFORE process
|
|
// teardown. The stop() calls are fire-and-forget via channel,
|
|
// so we must wait for the audio threads to actually drop their
|
|
// CPAL streams.
|
|
log::info!("RunEvent::Exit — stopping audio and visualizer");
|
|
|
|
if let Some(state) = app_handle.try_state::<AppState>() {
|
|
state.visualizer.set_enabled(false);
|
|
let _ = state.player.stop();
|
|
}
|
|
|
|
if let Some(bridge_state) = app_handle.try_state::<core_bridge::CoreBridgeState>()
|
|
{
|
|
if let Ok(guard) = bridge_state.0.try_read() {
|
|
if let Some(bridge) = guard.as_ref() {
|
|
let _ = bridge.player().stop();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wait for audio threads to process stop and drop CPAL streams.
|
|
// Without this, exit() can free heap while CPAL threads still run.
|
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
log::info!("RunEvent::Exit — shutdown complete");
|
|
}
|
|
_ => {}
|
|
}
|
|
});
|
|
}
|