Merge branch 'pr-181' into pre-release
No files matched your search
@@ -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};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 69 KiB |
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||