Merge branch 'pr-181' into pre-release

This commit is contained in:
victorrhgap committed 2026-03-24 19:28:57 -06:00
commit 9f7304b35b
22 files changed
+389 -31

No files matched your search

+11 -6
View File
@@ -7,10 +7,13 @@
use alsa::pcm::{Access, Format, HwParams, PCM};
#[cfg(target_os = "linux")]
use alsa::{Direction, ValueOr};
#[cfg(target_os = "linux")]
use std::sync::atomic::AtomicBool;
#[cfg(target_os = "linux")]
use std::sync::{Arc, Mutex};
/// Direct ALSA PCM stream for hw: devices
#[cfg(target_os = "linux")]
pub struct AlsaDirectStream {
pcm: Arc<Mutex<PCM>>,
#[allow(dead_code)]
@@ -21,9 +24,16 @@ pub struct AlsaDirectStream {
device_id: String,
}
#[cfg(not(target_os = "linux"))]
pub struct AlsaDirectStream {
sample_rate: u32,
channels: u16,
device_id: String,
}
#[cfg(target_os = "linux")]
impl AlsaDirectStream {
/// Create new ALSA direct stream
#[cfg(target_os = "linux")]
pub fn new(device_id: &str, sample_rate: u32, channels: u16) -> Result<Self, String> {
log::info!(
"[ALSA Direct] Opening device: {} ({}Hz, {}ch)",
@@ -127,7 +137,6 @@ impl AlsaDirectStream {
}
/// Write audio samples to ALSA (auto-converts from i16 based on detected format)
#[cfg(target_os = "linux")]
pub fn write(&self, samples_i16: &[i16]) -> Result<(), String> {
let pcm = self.pcm.lock().unwrap();
let frames = samples_i16.len() / self.channels as usize;
@@ -296,7 +305,6 @@ impl AlsaDirectStream {
///
/// f32 has 24 bits of significand, so 24-bit audio is preserved losslessly.
/// This is the primary write path for the f32 pipeline.
#[cfg(target_os = "linux")]
pub fn write_f32(&self, samples_f32: &[f32]) -> Result<(), String> {
let pcm = self.pcm.lock().unwrap();
let frames = samples_f32.len() / self.channels as usize;
@@ -469,7 +477,6 @@ impl AlsaDirectStream {
}
/// Drain and stop playback
#[cfg(target_os = "linux")]
pub fn drain(&self) -> Result<(), String> {
log::info!("[ALSA Direct] Draining PCM");
let pcm = self.pcm.lock().unwrap();
@@ -478,7 +485,6 @@ impl AlsaDirectStream {
}
/// Stop PCM immediately (prepare for next playback)
#[cfg(target_os = "linux")]
pub fn stop(&self) -> Result<(), String> {
log::info!("[ALSA Direct] Stopping PCM");
let pcm = self.pcm.lock().unwrap();
@@ -510,7 +516,6 @@ impl AlsaDirectStream {
/// - Mixer API fails
///
/// NOTE: Failure doesn't break playback, just means volume can't be controlled.
#[cfg(target_os = "linux")]
pub fn set_hardware_volume(&self, volume: f32) -> Result<(), String> {
use alsa::mixer::SelemChannelId::*;
use alsa::mixer::{Mixer, SelemId};
+137 -3
View File
@@ -4,6 +4,11 @@
//! allowing users to choose their preferred audio stack.
use rodio::MixerDeviceSink;
#[cfg(not(target_os = "linux"))]
use rodio::{
cpal::traits::{DeviceTrait, HostTrait},
DeviceSinkBuilder,
};
use serde::{Deserialize, Serialize};
/// Supported audio backends
@@ -26,12 +31,20 @@ pub enum AudioBackendType {
/// - Similar to PipeWire but older
/// - Fallback for systems without PipeWire
Pulse,
/// System default backend (non-Linux platforms)
/// - Uses CPAL default host (CoreAudio on macOS, WASAPI on Windows)
/// - Automatic device selection via OS audio system
SystemDefault,
}
impl Default for AudioBackendType {
fn default() -> Self {
// PipeWire is the modern default on Linux
if cfg!(target_os = "linux") {
AudioBackendType::PipeWire
} else {
AudioBackendType::SystemDefault
}
}
}
@@ -255,8 +268,7 @@ impl BackendManager {
#[cfg(not(target_os = "linux"))]
{
// On non-Linux, only PipeWire backend (which uses CPAL default)
backends.push(AudioBackendType::PipeWire);
backends.push(AudioBackendType::SystemDefault);
}
backends
@@ -266,9 +278,27 @@ impl BackendManager {
pub fn create_backend(backend_type: AudioBackendType) -> BackendResult<Box<dyn AudioBackend>> {
match backend_type {
AudioBackendType::PipeWire => {
#[cfg(target_os = "linux")]
{
let backend = crate::pipewire_backend::PipeWireBackend::new()?;
Ok(Box::new(backend))
}
#[cfg(not(target_os = "linux"))]
{
log::info!("PipeWire not available on this platform, using system default audio");
Ok(Box::new(CpalDefaultBackend::new()?))
}
}
AudioBackendType::SystemDefault => {
#[cfg(not(target_os = "linux"))]
{
Ok(Box::new(CpalDefaultBackend::new()?))
}
#[cfg(target_os = "linux")]
{
Err("SystemDefault backend is not available on Linux; use PipeWire, ALSA, or Pulse".to_string())
}
}
AudioBackendType::Alsa => {
#[cfg(target_os = "linux")]
{
@@ -281,9 +311,16 @@ impl BackendManager {
}
}
AudioBackendType::Pulse => {
#[cfg(target_os = "linux")]
{
let backend = crate::pulse_backend::PulseBackend::new()?;
Ok(Box::new(backend))
}
#[cfg(not(target_os = "linux"))]
{
Err("PulseAudio backend only available on Linux".to_string())
}
}
}
}
@@ -309,3 +346,100 @@ impl BackendManager {
.unwrap_or(false)
}
}
/// CPAL default backend for non-Linux platforms (macOS CoreAudio, Windows WASAPI).
/// Uses the system default audio device via CPAL without any platform-specific commands.
#[cfg(not(target_os = "linux"))]
pub struct CpalDefaultBackend {
host: rodio::cpal::Host,
}
#[cfg(not(target_os = "linux"))]
impl CpalDefaultBackend {
pub fn new() -> BackendResult<Self> {
Ok(Self {
host: rodio::cpal::default_host(),
})
}
}
#[cfg(not(target_os = "linux"))]
impl AudioBackend for CpalDefaultBackend {
fn backend_type(&self) -> AudioBackendType {
AudioBackendType::SystemDefault
}
fn enumerate_devices(&self) -> BackendResult<Vec<AudioDevice>> {
let default_device = self
.host
.default_output_device()
.ok_or_else(|| "No default output device found".to_string())?;
let default_name = default_device
.description()
.map(|desc| desc.name().to_string())
.unwrap_or_else(|_| "Default Output".to_string());
let mut devices = Vec::new();
for device in self
.host
.output_devices()
.map_err(|e| format!("Failed to enumerate output devices: {}", e))?
{
let name = device
.description()
.map(|desc| desc.name().to_string())
.unwrap_or_else(|_| "Unknown Device".to_string());
let is_default = name == default_name;
devices.push(AudioDevice {
id: name.clone(),
name,
description: None,
is_default,
max_sample_rate: None,
supported_sample_rates: None,
device_bus: None,
is_hardware: false,
});
}
Ok(devices)
}
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
let device = if let Some(ref device_id) = config.device_id {
self.host
.output_devices()
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
.find(|d| {
d.description()
.map(|desc| desc.name() == device_id.as_str())
.unwrap_or(false)
})
.ok_or_else(|| format!("Device '{}' not found", device_id))?
} else {
self.host
.default_output_device()
.ok_or_else(|| "No default output device found".to_string())?
};
let mixer_sink = DeviceSinkBuilder::from_device(device)
.map_err(|e| format!("Failed to create device sink builder: {}", e))?
.open_stream()
.map_err(|e| format!("Failed to create output stream: {}", e))?;
Ok(mixer_sink)
}
fn is_available(&self) -> bool {
self.host.default_output_device().is_some()
}
fn description(&self) -> &'static str {
"System Audio - Default audio output"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
+23 -3
View File
@@ -26,22 +26,26 @@
//! └───────────────┘
//! ```
pub mod backend;
#[cfg(target_os = "linux")]
pub mod pipewire_backend;
#[cfg(target_os = "linux")]
pub mod alsa_backend;
#[cfg(target_os = "linux")]
pub mod pulse_backend;
pub mod alsa_direct;
pub mod analysis;
pub mod analyzer_tap;
pub mod backend;
pub mod diagnostic;
pub mod dynamic_amplify;
pub mod loudness;
pub mod loudness_analyzer;
pub mod loudness_cache;
pub mod pipewire_backend;
pub mod pulse_backend;
pub mod settings;
pub mod visualizer;
// Re-export commonly used types
#[cfg(target_os = "linux")]
pub use alsa_backend::{
device_supports_sample_rate, get_device_supported_rates, normalize_device_id_to_stable,
resolve_stable_to_current_hw,
@@ -60,3 +64,19 @@ pub use loudness_analyzer::LoudnessAnalyzer;
pub use loudness_cache::LoudnessCache;
pub use settings::AudioSettings;
pub use visualizer::{RingBuffer, TappedSource, VisualizerTap};
/// Stub: returns the ID unchanged on non-Linux (no ALSA normalization needed).
#[cfg(not(target_os = "linux"))]
pub fn normalize_device_id_to_stable(id: &str) -> String { id.to_string() }
/// Stub: no ALSA device resolution on non-Linux.
#[cfg(not(target_os = "linux"))]
pub fn resolve_stable_to_current_hw(_stable: &str) -> Option<String> { None }
/// Stub: no ALSA sample rate probing on non-Linux.
#[cfg(not(target_os = "linux"))]
pub fn device_supports_sample_rate(_device_id: &str, _sample_rate: u32) -> Option<bool> { None }
/// Stub: no ALSA rate enumeration on non-Linux.
#[cfg(not(target_os = "linux"))]
pub fn get_device_supported_rates(_device_id: &str) -> Option<Vec<u32>> { None }
+2
View File
@@ -2003,6 +2003,7 @@ impl Player {
drop(stream_opt.take());
*pause_suspend_deadline = None;
// Reset PipeWire clock if bit-perfect was active
#[cfg(target_os = "linux")]
if thread_settings
.lock()
.ok()
@@ -2454,6 +2455,7 @@ impl Player {
drop(stream_opt.take());
pause_suspend_deadline = None;
// Reset PipeWire clock if bit-perfect was active
#[cfg(target_os = "linux")]
if thread_settings
.lock()
.ok()
+50 -2
View File
@@ -3202,6 +3202,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac-notification-sys"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
dependencies = [
"cc",
"objc2",
"objc2-foundation",
"time",
]
[[package]]
name = "mach2"
version = "0.5.0"
@@ -3484,6 +3496,20 @@ dependencies = [
"memchr",
]
[[package]]
name = "notify-rust"
version = "4.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -4163,7 +4189,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.0",
"quick-xml",
"quick-xml 0.38.4",
"serde",
"time",
]
@@ -4597,6 +4623,7 @@ dependencies = [
"log",
"md-5",
"mupdf",
"notify-rust",
"open",
"qbz-audio",
"qbz-cache",
@@ -4754,6 +4781,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -6610,6 +6646,18 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.18",
"windows 0.61.3",
"windows-version",
]
[[package]]
name = "tempfile"
version = "3.26.0"
@@ -7539,7 +7587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
dependencies = [
"proc-macro2",
"quick-xml",
"quick-xml 0.38.4",
"quote",
]
+2 -1
View File
@@ -32,7 +32,7 @@ qconnect-core = { path = "../crates/qconnect-core" }
qconnect-transport-ws = { path = "../crates/qconnect-transport-ws" }
# Tauri
tauri = { version = "2", features = ["protocol-asset", "tray-icon"] }
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "macos-private-api"] }
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
@@ -152,6 +152,7 @@ alsa = "0.9"
[target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-dialog = { version = "2.6.0" }
notify-rust = "4"
[target.'cfg(target_os = "windows")'.dependencies]
tauri-plugin-dialog = { version = "2.6.0" }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 69 KiB

+82 -7
View File
@@ -64,11 +64,14 @@ use crate::runtime::{
RuntimeStatus,
};
use crate::AppState;
#[cfg(target_os = "linux")]
use ashpd::desktop::notification::{Notification as PortalNotification, NotificationProxy};
#[cfg(target_os = "linux")]
use ashpd::desktop::Icon;
use md5::{Digest, Md5};
use std::collections::HashSet;
use std::fs;
#[cfg(target_os = "linux")]
use std::io::{Cursor, Write};
use std::path::PathBuf;
use std::sync::Mutex;
@@ -285,6 +288,7 @@ fn limit_quality_for_device(quality: Quality, max_sample_rate: Option<u32>) -> Q
/// Probe sample rate from FLAC audio data by reading the STREAMINFO header.
/// Returns None for non-FLAC data or data too short to parse.
#[cfg(target_os = "linux")]
fn probe_flac_sample_rate(data: &[u8]) -> Option<u32> {
// FLAC format: "fLaC" magic + metadata blocks
// First block is always STREAMINFO (34 bytes)
@@ -624,9 +628,12 @@ fn v2_teardown_type_alias_state<S>(state: &Arc<Mutex<Option<S>>>) {
}
}
#[cfg(target_os = "linux")]
const PORTAL_NOTIFICATION_ICON_MAX_EDGE: u32 = 512;
#[cfg(target_os = "linux")]
const PORTAL_NOTIFICATION_ICON_MAX_BYTES: usize = 4 * 1024 * 1024;
#[cfg(target_os = "linux")]
fn v2_get_notification_artwork_cache_dir() -> Result<PathBuf, String> {
let cache_dir = dirs::cache_dir()
.ok_or_else(|| "Could not find cache directory".to_string())?
@@ -638,6 +645,7 @@ fn v2_get_notification_artwork_cache_dir() -> Result<PathBuf, String> {
Ok(cache_dir)
}
#[cfg(target_os = "linux")]
fn v2_resolve_local_artwork(url: &str) -> Option<PathBuf> {
if let Some(path) = url.strip_prefix("file://") {
return Some(PathBuf::from(path));
@@ -649,6 +657,7 @@ fn v2_resolve_local_artwork(url: &str) -> Option<PathBuf> {
None
}
#[cfg(target_os = "linux")]
fn v2_cache_notification_artwork(url: &str) -> Result<PathBuf, String> {
if let Some(local_path) = v2_resolve_local_artwork(url) {
if local_path.exists() {
@@ -689,6 +698,7 @@ fn v2_cache_notification_artwork(url: &str) -> Result<PathBuf, String> {
Ok(cache_path)
}
#[cfg(target_os = "linux")]
fn v2_prepare_notification_icon_bytes(path: &std::path::Path) -> Result<Vec<u8>, String> {
let source_image = image::open(path)
.map_err(|e| format!("Failed to decode artwork image {:?}: {}", path, e))?;
@@ -2300,6 +2310,7 @@ pub fn v2_get_available_backends() -> Result<Vec<BackendInfo>, String> {
AudioBackendType::PipeWire => "PipeWire",
AudioBackendType::Alsa => "ALSA Direct",
AudioBackendType::Pulse => "PulseAudio",
AudioBackendType::SystemDefault => "System Audio",
};
BackendInfo {
@@ -2412,6 +2423,8 @@ pub fn v2_query_dac_capabilities(nodeName: String) -> Result<DacCapabilities, St
}
// Detect real sample rates from /proc/asound via PipeWire sink -> ALSA card mapping
#[cfg(target_os = "linux")]
{
if let Some(rates) =
crate::audio::pipewire_backend::PipeWireBackend::get_sink_supported_rates(&nodeName)
{
@@ -2437,6 +2450,15 @@ pub fn v2_query_dac_capabilities(nodeName: String) -> Result<DacCapabilities, St
);
}
}
}
#[cfg(not(target_os = "linux"))]
{
log::info!(
"[HiFi Wizard] Hardware sample rate detection not yet implemented on this platform for {}",
nodeName
);
}
Ok(capabilities)
}
@@ -2968,6 +2990,7 @@ pub fn v2_get_qobuz_track_url(trackId: u64) -> Result<String, RuntimeError> {
}
/// Known .desktop filenames across packaging formats.
#[cfg(target_os = "linux")]
const QBZ_DESKTOP_CANDIDATES: &[&str] = &[
"com.blitzfc.qbz.desktop", // Tauri deb, Flatpak
"qbz.desktop", // Arch, AUR, Snap
@@ -2975,6 +2998,7 @@ const QBZ_DESKTOP_CANDIDATES: &[&str] = &[
];
/// Search standard directories for the installed QBZ .desktop file.
#[cfg(target_os = "linux")]
fn find_qbz_desktop_file() -> String {
let home = std::env::var("HOME").unwrap_or_default();
let search_dirs = [
@@ -3000,6 +3024,7 @@ fn find_qbz_desktop_file() -> String {
}
/// Refresh the desktop MIME database so xdg-open picks up changes.
#[cfg(target_os = "linux")]
fn refresh_desktop_database() {
// User-level applications dir
if let Some(data_dir) = dirs::data_dir() {
@@ -3019,6 +3044,8 @@ fn refresh_desktop_database() {
/// Check if QBZ is the default handler for qobuzapp:// links.
#[tauri::command]
pub fn v2_check_qobuzapp_handler() -> Result<bool, RuntimeError> {
#[cfg(target_os = "linux")]
{
let output = std::process::Command::new("xdg-mime")
.args(["query", "default", "x-scheme-handler/qobuzapp"])
.output()
@@ -3027,10 +3054,18 @@ pub fn v2_check_qobuzapp_handler() -> Result<bool, RuntimeError> {
let result = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(QBZ_DESKTOP_CANDIDATES.iter().any(|c| *c == result))
}
#[cfg(not(target_os = "linux"))]
{
// URI handler registration is Linux-only (xdg-mime)
Ok(false)
}
}
/// Register QBZ as the default handler for qobuzapp:// links.
#[tauri::command]
pub fn v2_register_qobuzapp_handler() -> Result<bool, RuntimeError> {
#[cfg(target_os = "linux")]
{
let desktop_file = find_qbz_desktop_file();
log::info!(
"[URI Handler] Registering {} for x-scheme-handler/qobuzapp",
@@ -3051,10 +3086,17 @@ pub fn v2_register_qobuzapp_handler() -> Result<bool, RuntimeError> {
log::info!("[URI Handler] Registration complete, desktop database refreshed");
Ok(true)
}
#[cfg(not(target_os = "linux"))]
{
Ok(false)
}
}
/// Remove QBZ as the default handler for qobuzapp:// links.
#[tauri::command]
pub fn v2_deregister_qobuzapp_handler() -> Result<bool, RuntimeError> {
#[cfg(target_os = "linux")]
{
let mimeapps = dirs::config_dir()
.ok_or_else(|| RuntimeError::Internal("No config dir found".to_string()))?
.join("mimeapps.list");
@@ -3085,6 +3127,11 @@ pub fn v2_deregister_qobuzapp_handler() -> Result<bool, RuntimeError> {
refresh_desktop_database();
Ok(true)
}
#[cfg(not(target_os = "linux"))]
{
Ok(true)
}
}
#[tauri::command]
#[allow(non_snake_case)]
@@ -11216,6 +11263,8 @@ pub async fn v2_show_track_notification(
artist
);
let body_text = {
let separator = if cfg!(target_os = "macos") { " \u{00b7} " } else { " \u{2022} " };
let mut lines = Vec::new();
let mut line1_parts = Vec::new();
if !artist.is_empty() {
@@ -11225,7 +11274,7 @@ pub async fn v2_show_track_notification(
line1_parts.push(album.clone());
}
if !line1_parts.is_empty() {
lines.push(line1_parts.join(" • "));
lines.push(line1_parts.join(separator));
}
let quality = v2_format_notification_quality(bit_depth, sample_rate);
@@ -11233,8 +11282,13 @@ pub async fn v2_show_track_notification(
lines.push(quality);
}
let body_text = lines.join("\n");
let mut notification = PortalNotification::new(&title).body(Some(body_text.as_str()));
lines.join("\n")
};
#[cfg(target_os = "linux")]
{
let mut notification = PortalNotification::new(&title)
.body(Some(body_text.as_str()));
if let Some(ref url_str) = artwork_url {
let url_clone = url_str.clone();
@@ -11260,10 +11314,7 @@ pub async fn v2_show_track_notification(
match NotificationProxy::new().await {
Ok(proxy) => {
if let Err(e) = proxy
.add_notification("track-now-playing", notification)
.await
{
if let Err(e) = proxy.add_notification("track-now-playing", notification).await {
log::warn!("Could not show notification via XDG portal: {}", e);
}
}
@@ -11271,6 +11322,30 @@ pub async fn v2_show_track_notification(
log::warn!("XDG notification portal unavailable: {}", e);
}
}
}
#[cfg(target_os = "macos")]
{
let _ = &artwork_url; // macOS notify-rust doesn't support custom artwork
// Fire-and-forget: notification delivery shouldn't block track playback response
tokio::task::spawn_blocking(move || {
let _ = notify_rust::set_application("com.blitzfc.qbz");
if let Err(e) = notify_rust::Notification::new()
.summary(&title)
.body(&body_text)
.show()
{
log::warn!("Failed to show macOS notification: {}", e);
}
});
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = (&body_text, &artwork_url);
log::info!("Desktop notifications not implemented on this platform");
}
Ok(())
}
+27 -5
View File
@@ -169,6 +169,7 @@ pub fn update_media_controls_metadata(
/// 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()
@@ -297,6 +298,7 @@ fn setup_kwin_window_rule() -> Result<(), String> {
}
/// 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
@@ -411,7 +413,12 @@ fn should_use_main_window_transparency() -> bool {
#[cfg(not(target_os = "linux"))]
fn should_use_main_window_transparency() -> bool {
true
// 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)
}
pub fn run() {
@@ -545,6 +552,7 @@ pub fn run() {
// 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) {
@@ -718,7 +726,8 @@ pub fn run() {
"Main window transparency: {} (override with QBZ_FORCE_TRANSPARENT_WINDOWS=1 or QBZ_FORCE_OPAQUE_WINDOWS=1)",
main_window_transparent
);
let main_window = tauri::WebviewWindowBuilder::new(
#[allow(unused_mut)]
let mut builder = tauri::WebviewWindowBuilder::new(
app,
"main",
tauri::WebviewUrl::App(std::path::PathBuf::from("index.html")),
@@ -726,11 +735,24 @@ pub fn run() {
.title("QBZ")
.inner_size(saved_win_width, saved_win_height)
.min_inner_size(800.0, 600.0)
.decorations(use_system_titlebar)
.decorations(if cfg!(target_os = "macos") { true } else { use_system_titlebar })
.transparent(main_window_transparent)
.resizable(true)
.zoom_hotkeys_enabled(true)
.build()
.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
+3 -3
View File
@@ -119,7 +119,7 @@ impl MediaControlsManager {
};
if let Err(e) = controls.set_playback(playback) {
log::debug!("Failed to set playback state: {}", e);
log::debug!("Failed to set playback state: {:?}", e);
}
}
}
@@ -139,7 +139,7 @@ impl MediaControlsManager {
};
if let Err(e) = controls.set_playback(playback) {
log::debug!("Failed to set playback state: {}", e);
log::debug!("Failed to set playback state: {:?}", e);
}
}
}
@@ -150,7 +150,7 @@ impl MediaControlsManager {
if let Ok(mut guard) = self.controls.lock() {
if let Some(controls) = guard.as_mut() {
if let Err(e) = controls.set_playback(MediaPlayback::Stopped) {
log::debug!("Failed to set stopped state: {}", e);
log::debug!("Failed to set stopped state: {:?}", e);
}
}
}
+10 -1
View File
@@ -115,6 +115,7 @@ impl MountCache {
}
/// Classify a filesystem type into MountKind
#[cfg(target_os = "linux")]
fn classify_fs_type(fs_type: &str, source: &str) -> MountKind {
let fs_lower = fs_type.to_lowercase();
@@ -192,6 +193,7 @@ fn classify_fs_type(fs_type: &str, source: &str) -> MountKind {
}
/// Parse /proc/self/mountinfo and return mount information
#[cfg(target_os = "linux")]
fn parse_mount_info() -> Result<Vec<MountInfo>, String> {
let content = fs::read_to_string("/proc/self/mountinfo")
.map_err(|e| format!("Failed to read /proc/self/mountinfo: {}", e))?;
@@ -209,6 +211,7 @@ fn parse_mount_info() -> Result<Vec<MountInfo>, String> {
/// Parse a single line from /proc/self/mountinfo
/// Format: mount_id parent_id major:minor root mount_point mount_options ... - fs_type mount_source super_options
#[cfg(target_os = "linux")]
fn parse_mount_line(line: &str) -> Option<MountInfo> {
let parts: Vec<&str> = line.split_whitespace().collect();
@@ -245,6 +248,7 @@ fn parse_mount_line(line: &str) -> Option<MountInfo> {
}
/// Unescape mount path (handles \040 for space, etc.)
#[cfg(target_os = "linux")]
fn unescape_mount_path(path: &str) -> String {
let mut result = String::with_capacity(path.len());
let mut chars = path.chars().peekable();
@@ -417,7 +421,12 @@ pub mod commands {
}
}
#[cfg(test)]
#[cfg(not(target_os = "linux"))]
fn parse_mount_info() -> Result<Vec<MountInfo>, String> {
Ok(Vec::new())
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
+4
View File
@@ -10,6 +10,7 @@
"frontendDist": "../build"
},
"app": {
"macOSPrivateApi": true,
"windows": [],
"security": {
"csp": null,
@@ -43,6 +44,9 @@
"category": "Music",
"shortDescription": "Native Qobuz client with hi-res audio support",
"longDescription": "QBZ is a native high-fidelity client for Qobuz\u2122 subscribers on Linux, designed for audiophiles who need bit-perfect playback without browser sample rate limitations. This application uses the Qobuz API but is not certified by Qobuz.",
"macOS": {
"minimumSystemVersion": "10.15"
},
"linux": {
"deb": {
"depends": [],
+11
View File
@@ -22,6 +22,17 @@
/* Hide custom title bar before Svelte hydrates when setting is active */
html[data-no-titlebar] .titlebar { display: none !important; }
</style>
<script>
// Detect macOS for platform-specific CSS and behavior.
// Prevent trackpad pinch-to-zoom and Cmd+scroll zoom in WKWebView —
// this is a desktop app, not a web page, so accidental zoom breaks the UI.
if (/Mac/.test(navigator.platform)) {
document.documentElement.classList.add('macos');
document.addEventListener('gesturestart', function(e) { e.preventDefault(); });
document.addEventListener('gesturechange', function(e) { e.preventDefault(); });
document.addEventListener('gestureend', function(e) { e.preventDefault(); });
}
</script>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+5
View File
@@ -2090,6 +2090,11 @@
height: calc(100vh - 104px); /* Only 104px NowPlayingBar, no title bar */
}
/* macOS: pad top of sidebar to clear native traffic light buttons */
:global(html.macos) .sidebar.no-titlebar {
padding-top: 32px;
}
.content {
flex: 1;
overflow: hidden;
+6
View File
@@ -14,6 +14,9 @@ const STORAGE_KEY_HIDE = 'qbz-hide-titlebar';
const STORAGE_KEY_SYSTEM = 'qbz-use-system-titlebar';
const STORAGE_KEY_WINDOW_CONTROLS = 'qbz-show-window-controls';
// Platform detection (cached once — never changes at runtime)
const isMacOS = typeof document !== 'undefined' && document.documentElement.classList.contains('macos');
// State
let hideTitleBar = false;
let useSystemTitleBar = false;
@@ -87,6 +90,8 @@ export function getUseSystemTitleBar(): boolean {
* Hidden when either system title bar is active or hide mode is on
*/
export function shouldShowTitleBar(): boolean {
// macOS always uses native decorations — no custom title bar
if (isMacOS) return false;
return !hideTitleBar && !useSystemTitleBar;
}
@@ -95,6 +100,7 @@ export function shouldShowTitleBar(): boolean {
* Returns 0 if title bar is hidden or system title bar is active, 40 otherwise
*/
export function getTitleBarHeight(): number {
if (isMacOS) return 0;
return (hideTitleBar || useSystemTitleBar) ? 0 : 40;
}
+16
View File
@@ -476,6 +476,7 @@
// Title Bar State (from titleBarStore subscription)
let showTitleBar = $state(shouldShowTitleBar());
let showWindowControls = $state(getShowWindowControls());
const isMacOS = typeof document !== 'undefined' && document.documentElement.classList.contains('macos');
// Search Bar Location State
let searchBarLocationPref = $state(getSearchBarLocation());
@@ -4770,6 +4771,10 @@
<LoginView onLoginSuccess={handleLoginSuccess} onStartOffline={handleStartOffline} />
{:else}
<div class="app" class:no-titlebar={!showTitleBar} class:floating={isWindowFloating}>
<!-- macOS: drag region for window movement (overlay title bar has no native drag area) -->
{#if !showTitleBar && isMacOS}
<div class="macos-drag-region" data-tauri-drag-region></div>
{/if}
<!-- Custom Title Bar (CSD) -->
{#if showTitleBar}
<TitleBar
@@ -5896,6 +5901,17 @@
height: calc(100vh - 104px); /* Only 104px NowPlayingBar, no title bar */
}
/* macOS: invisible drag region for window movement (overlay title bar) */
:global(html.macos) .macos-drag-region {
height: 28px;
width: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 9999;
-webkit-app-region: drag;
}
.view-error {
display: flex;
flex-direction: column;