Merge remote-tracking branch 'origin/pre-release' into bugfix/external/local-library-gapless
This commit is contained in:
commit
0c0ea645a8
173 files changed
+6728
-2873
No files matched your search
@@ -13,7 +13,13 @@ on:
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
# Apple Silicon only — Intel Macs run via Rosetta 2
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-apple-darwin
|
||||
artifact: qbz-macos-aarch64
|
||||
- target: x86_64-apple-darwin
|
||||
artifact: qbz-macos-x86_64
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -78,29 +84,30 @@ jobs:
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri -> target
|
||||
shared-key: macos-${{ matrix.target }}
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build DMG
|
||||
run: npm run tauri build -- --target aarch64-apple-darwin --bundles dmg
|
||||
run: npm run tauri build -- --target ${{ matrix.target }} --bundles dmg
|
||||
|
||||
- name: Collect artifacts
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp -v src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg dist/ || true
|
||||
cp -v src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg dist/ || true
|
||||
ls -la dist
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qbz-macos-aarch64
|
||||
name: ${{ matrix.artifact }}
|
||||
path: dist/*
|
||||
|
||||
- name: Publish GitHub Release Assets
|
||||
|
||||
@@ -95,7 +95,7 @@ Download from [Releases](https://github.com/vicrodh/qbz/releases): `chmod +x QBZ
|
||||
|
||||
> **QBZ is a Linux-first application.** macOS support is experimental and limited. Features like PipeWire, ALSA Direct, casting, and device control are unavailable.
|
||||
|
||||
Download the unsigned DMG from [Releases](https://github.com/vicrodh/qbz/releases) (Apple Silicon only).
|
||||
Download the unsigned DMG from [Releases](https://github.com/vicrodh/qbz/releases).
|
||||
|
||||
Since the DMG is unsigned, you may need to allow it in System Settings > Privacy & Security after first launch.
|
||||
|
||||
|
||||
@@ -39,3 +39,7 @@ dirs = "5"
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# ALSA for direct hardware access (bit-perfect)
|
||||
alsa = "0.9"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# CoreAudio for device probing, sample rate switching, and (future) hog mode / exclusive mode
|
||||
coreaudio-rs = "0.14.1"
|
||||
@@ -128,6 +128,10 @@ pub struct BackendConfig {
|
||||
|
||||
/// When true, force PipeWire clock.force-quantum for bit-perfect playback
|
||||
pub pw_force_bitperfect: bool,
|
||||
|
||||
/// When true, skip `pactl set-default-sink` on stream creation.
|
||||
/// Preserves external routing (JACK, qjackctl, Reaper).
|
||||
pub skip_sink_switch: bool,
|
||||
}
|
||||
|
||||
/// Result type for backend operations
|
||||
@@ -391,15 +395,29 @@ impl AudioBackend for CpalDefaultBackend {
|
||||
.map(|desc| desc.name().to_string())
|
||||
.unwrap_or_else(|_| "Unknown Device".to_string());
|
||||
let is_default = name == default_name;
|
||||
|
||||
// On macOS, probe device capabilities via CoreAudio
|
||||
#[cfg(target_os = "macos")]
|
||||
let (supported_rates, max_rate, bus_type, is_hw) = {
|
||||
Self::probe_macos_device(&name)
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let (supported_rates, max_rate, bus_type, is_hw): (
|
||||
Option<Vec<u32>>,
|
||||
Option<u32>,
|
||||
Option<String>,
|
||||
bool,
|
||||
) = (None, None, None, false);
|
||||
|
||||
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,
|
||||
max_sample_rate: max_rate,
|
||||
supported_sample_rates: supported_rates,
|
||||
device_bus: bus_type,
|
||||
is_hardware: is_hw,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -407,6 +425,17 @@ impl AudioBackend for CpalDefaultBackend {
|
||||
}
|
||||
|
||||
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
|
||||
// On macOS, switch device sample rate to match the requested rate
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(ref device_id) = config.device_id {
|
||||
Self::switch_sample_rate_if_needed(device_id, config.sample_rate);
|
||||
} else {
|
||||
// Switch default device rate
|
||||
Self::switch_default_device_rate_if_needed(config.sample_rate);
|
||||
}
|
||||
}
|
||||
|
||||
let device = if let Some(ref device_id) = config.device_id {
|
||||
self.host
|
||||
.output_devices()
|
||||
@@ -443,3 +472,106 @@ impl AudioBackend for CpalDefaultBackend {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl CpalDefaultBackend {
|
||||
/// Probe a macOS audio device for capabilities via CoreAudio APIs.
|
||||
/// Returns (supported_rates, max_rate, bus_type, is_hardware).
|
||||
fn probe_macos_device(
|
||||
device_name: &str,
|
||||
) -> (Option<Vec<u32>>, Option<u32>, Option<String>, bool) {
|
||||
use crate::coreaudio_direct;
|
||||
|
||||
let device_id = match coreaudio_direct::find_device_by_name(device_name) {
|
||||
Ok(Some(id)) => {
|
||||
log::info!("[CoreAudio] Found device '{}' with ID {}", device_name, id);
|
||||
id
|
||||
}
|
||||
Ok(None) => {
|
||||
log::debug!("[CoreAudio] Device '{}' not found via CoreAudio", device_name);
|
||||
return (None, None, None, false);
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("[CoreAudio] Error finding device '{}': {}", device_name, e);
|
||||
return (None, None, None, false);
|
||||
}
|
||||
};
|
||||
|
||||
let supported_rates = coreaudio_direct::query_supported_sample_rates(device_id)
|
||||
.inspect(|rates| log::info!("[CoreAudio] Device '{}' supported rates: {:?}", device_name, rates))
|
||||
.inspect_err(|e| log::warn!("[CoreAudio] Failed to query rates for '{}': {}", device_name, e))
|
||||
.ok()
|
||||
.filter(|r| !r.is_empty());
|
||||
let max_rate = supported_rates
|
||||
.as_ref()
|
||||
.and_then(|rates| rates.iter().max().copied());
|
||||
let bus_type = coreaudio_direct::get_device_transport_type(device_id);
|
||||
let is_hardware = bus_type
|
||||
.as_deref()
|
||||
.is_some_and(|t| t == "usb" || t == "built-in" || t == "thunderbolt" || t == "firewire");
|
||||
|
||||
(supported_rates, max_rate, bus_type, is_hardware)
|
||||
}
|
||||
|
||||
/// Switch device sample rate before stream creation (if device supports the target rate).
|
||||
fn switch_sample_rate_if_needed(device_name: &str, target_rate: u32) {
|
||||
use crate::coreaudio_direct;
|
||||
|
||||
log::info!("[CoreAudio] Rate switch requested: device='{}' target={}Hz", device_name, target_rate);
|
||||
|
||||
let device_id = match coreaudio_direct::find_device_by_name(device_name) {
|
||||
Ok(Some(id)) => id,
|
||||
Ok(None) => {
|
||||
log::warn!("[CoreAudio] Cannot switch rate: device '{}' not found", device_name);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[CoreAudio] Cannot switch rate: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if device supports the target rate
|
||||
if let Ok(rates) = coreaudio_direct::query_supported_sample_rates(device_id) {
|
||||
if !rates.contains(&target_rate) {
|
||||
log::debug!(
|
||||
"[CoreAudio] Device '{}' does not support {}Hz, skipping rate switch",
|
||||
device_name,
|
||||
target_rate
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = coreaudio_direct::set_nominal_sample_rate(device_id, target_rate) {
|
||||
log::warn!("[CoreAudio] Failed to switch sample rate: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the default output device's sample rate.
|
||||
fn switch_default_device_rate_if_needed(target_rate: u32) {
|
||||
use crate::coreaudio_direct;
|
||||
|
||||
let device_id = match coreaudio_direct::get_default_output_device() {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
log::debug!("[CoreAudio] Could not get default device for rate switch: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(rates) = coreaudio_direct::query_supported_sample_rates(device_id) {
|
||||
if !rates.contains(&target_rate) {
|
||||
log::debug!(
|
||||
"[CoreAudio] Default device does not support {}Hz, skipping rate switch",
|
||||
target_rate
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = coreaudio_direct::set_nominal_sample_rate(device_id, target_rate) {
|
||||
log::warn!("[CoreAudio] Failed to switch default device sample rate: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! CoreAudio direct access for macOS
|
||||
//!
|
||||
//! Provides device capability probing and sample rate switching on macOS
|
||||
//! using the coreaudio-rs safe wrappers.
|
||||
//!
|
||||
//! Phase 1: Device probing + nominal sample rate switching (shared mode)
|
||||
//! Phase 2 (future): Hog mode + integer mode + IO proc for bit-perfect playback
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use coreaudio::audio_unit::macos_helpers;
|
||||
#[cfg(target_os = "macos")]
|
||||
use coreaudio::audio_unit::Scope;
|
||||
|
||||
/// CoreAudio device ID (re-exported so callers don't need objc2_core_audio)
|
||||
#[cfg(target_os = "macos")]
|
||||
pub type AudioDeviceID = u32;
|
||||
|
||||
// CoreAudio transport type constants (FourCC values from AudioHardware.h)
|
||||
#[cfg(target_os = "macos")]
|
||||
mod transport_types {
|
||||
pub const BUILT_IN: u32 = 0x626c746e; // 'bltn'
|
||||
pub const USB: u32 = 0x75736220; // 'usb '
|
||||
pub const BLUETOOTH: u32 = 0x626c7565; // 'blue'
|
||||
pub const BLUETOOTH_LE: u32 = 0x626c6561; // 'blea'
|
||||
pub const HDMI: u32 = 0x68646d69; // 'hdmi'
|
||||
pub const DISPLAY_PORT: u32 = 0x64707274; // 'dprt'
|
||||
pub const THUNDERBOLT: u32 = 0x7468756e; // 'thun'
|
||||
pub const FIREWIRE: u32 = 0x31333934; // '1394'
|
||||
pub const VIRTUAL: u32 = 0x76697274; // 'virt'
|
||||
pub const AGGREGATE: u32 = 0x67727570; // 'grup'
|
||||
}
|
||||
|
||||
/// Common audio sample rates to check against device capabilities
|
||||
#[cfg(target_os = "macos")]
|
||||
const COMMON_SAMPLE_RATES: &[u32] = &[
|
||||
44100, 48000, 88200, 96000, 176400, 192000, 352800, 384000, 705600, 768000,
|
||||
];
|
||||
|
||||
/// Query supported sample rates for a CoreAudio device.
|
||||
/// Returns discrete rates from the device's available nominal sample rate ranges.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn query_supported_sample_rates(device_id: AudioDeviceID) -> Result<Vec<u32>, String> {
|
||||
let ranges = macos_helpers::get_available_sample_rates(device_id)
|
||||
.map_err(|e| format!("Failed to get sample rate ranges: {:?}", e))?;
|
||||
|
||||
let mut rates = Vec::new();
|
||||
for range in &ranges {
|
||||
if (range.mMinimum - range.mMaximum).abs() < 0.5 {
|
||||
// Point value (min == max)
|
||||
rates.push(range.mMinimum as u32);
|
||||
} else {
|
||||
// Continuous range — check which common rates fall within it
|
||||
for &rate in COMMON_SAMPLE_RATES {
|
||||
let rate_f = rate as f64;
|
||||
if rate_f >= range.mMinimum && rate_f <= range.mMaximum {
|
||||
rates.push(rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rates.sort_unstable();
|
||||
rates.dedup();
|
||||
Ok(rates)
|
||||
}
|
||||
|
||||
/// Set the nominal sample rate of a device.
|
||||
/// Delegates to coreaudio-rs which handles async confirmation with a 2-second timeout.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn set_nominal_sample_rate(device_id: AudioDeviceID, target_rate: u32) -> Result<(), String> {
|
||||
log::info!(
|
||||
"[CoreAudio] Switching sample rate to {}Hz on device {}",
|
||||
target_rate,
|
||||
device_id
|
||||
);
|
||||
|
||||
macos_helpers::set_device_sample_rate(device_id, target_rate as f64)
|
||||
.map_err(|e| format!("Failed to set sample rate to {}Hz: {:?}", target_rate, e))?;
|
||||
|
||||
log::info!("[CoreAudio] Sample rate switched to {}Hz", target_rate);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the default output device ID.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn get_default_output_device() -> Result<AudioDeviceID, String> {
|
||||
macos_helpers::get_default_device_id(false)
|
||||
.ok_or_else(|| "No default output device found".to_string())
|
||||
}
|
||||
|
||||
/// Get all output device IDs.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn get_output_device_ids() -> Result<Vec<AudioDeviceID>, String> {
|
||||
macos_helpers::get_audio_device_ids_for_scope(Scope::Output)
|
||||
.map_err(|e| format!("Failed to enumerate output devices: {:?}", e))
|
||||
}
|
||||
|
||||
/// Get the name of a CoreAudio device.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn get_device_name(device_id: AudioDeviceID) -> Result<String, String> {
|
||||
macos_helpers::get_device_name(device_id)
|
||||
.map_err(|e| format!("Failed to get device name: {:?}", e))
|
||||
}
|
||||
|
||||
/// Find a CoreAudio output device ID by its name.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn find_device_by_name(name: &str) -> Result<Option<AudioDeviceID>, String> {
|
||||
// get_device_id_from_name: input=false means output device
|
||||
Ok(macos_helpers::get_device_id_from_name(name, false))
|
||||
}
|
||||
|
||||
/// Get the transport type of a device (USB, built-in, Bluetooth, etc.)
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn get_device_transport_type(device_id: AudioDeviceID) -> Option<String> {
|
||||
let transport = macos_helpers::get_device_transport_type(device_id).ok()?;
|
||||
|
||||
let transport_str = if transport == transport_types::BUILT_IN {
|
||||
"built-in"
|
||||
} else if transport == transport_types::USB {
|
||||
"usb"
|
||||
} else if transport == transport_types::BLUETOOTH
|
||||
|| transport == transport_types::BLUETOOTH_LE
|
||||
{
|
||||
"bluetooth"
|
||||
} else if transport == transport_types::HDMI
|
||||
|| transport == transport_types::DISPLAY_PORT
|
||||
{
|
||||
"hdmi"
|
||||
} else if transport == transport_types::THUNDERBOLT {
|
||||
"thunderbolt"
|
||||
} else if transport == transport_types::FIREWIRE {
|
||||
"firewire"
|
||||
} else if transport == transport_types::VIRTUAL {
|
||||
"virtual"
|
||||
} else if transport == transport_types::AGGREGATE {
|
||||
"aggregate"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
Some(transport_str.to_string())
|
||||
}
|
||||
|
||||
// ---- Non-macOS stubs ----
|
||||
|
||||
/// Query supported sample rates (stub for non-macOS)
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn query_supported_sample_rates(_device_name: &str) -> Result<Vec<u32>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Get the current nominal sample rate (stub for non-macOS)
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn get_nominal_sample_rate_by_name(_device_name: &str) -> Result<u32, String> {
|
||||
Err("CoreAudio is only available on macOS".to_string())
|
||||
}
|
||||
|
||||
/// Set the nominal sample rate (stub for non-macOS)
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn set_nominal_sample_rate_by_name(
|
||||
_device_name: &str,
|
||||
_target_rate: u32,
|
||||
) -> Result<(), String> {
|
||||
Err("CoreAudio is only available on macOS".to_string())
|
||||
}
|
||||
@@ -34,6 +34,7 @@ pub mod alsa_backend;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod pulse_backend;
|
||||
pub mod alsa_direct;
|
||||
pub mod coreaudio_direct;
|
||||
pub mod analysis;
|
||||
pub mod analyzer_tap;
|
||||
pub mod diagnostic;
|
||||
|
||||
@@ -308,7 +308,10 @@ impl AudioBackend for PipeWireBackend {
|
||||
// Temporarily set default sink to target (if specified)
|
||||
// We DON'T restore it - let the user's system keep the selected device as default
|
||||
// This is actually the expected behavior: when you select a device, it becomes the default
|
||||
if let Some(sink_name) = &target_sink {
|
||||
// When skip_sink_switch is true, skip this entirely to preserve external routing (JACK/qjackctl)
|
||||
if config.skip_sink_switch {
|
||||
log::info!("[PipeWire Backend] Skipping set-default-sink (skip_sink_switch enabled)");
|
||||
} else if let Some(sink_name) = &target_sink {
|
||||
log::info!("[PipeWire Backend] Setting default sink to: {}", sink_name);
|
||||
|
||||
let set_result = Command::new("pactl")
|
||||
|
||||
@@ -50,6 +50,11 @@ pub struct AudioSettings {
|
||||
/// When true, force PipeWire clock.force-quantum alongside clock.force-rate for bit-perfect.
|
||||
/// Reset both to 0 on stop. PipeWire-only, requires dac_passthrough.
|
||||
pub pw_force_bitperfect: bool,
|
||||
/// When true, skip `pactl set-default-sink` on stream creation.
|
||||
/// Preserves external routing (JACK, qjackctl, Reaper).
|
||||
pub skip_sink_switch: bool,
|
||||
/// When true, automatically try lower quality tiers if the requested one fails.
|
||||
pub allow_quality_fallback: bool,
|
||||
}
|
||||
|
||||
impl Default for AudioSettings {
|
||||
@@ -72,6 +77,8 @@ impl Default for AudioSettings {
|
||||
normalization_target_lufs: -14.0, // Spotify/YouTube standard
|
||||
gapless_enabled: false, // Off by default — user opts in
|
||||
pw_force_bitperfect: false, // Off by default — experimental PipeWire feature
|
||||
skip_sink_switch: false, // Off by default — only for JACK/DAW routing setups
|
||||
allow_quality_fallback: false, // Off by default — fail rather than silently downgrade
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,6 +167,14 @@ impl AudioSettingsStore {
|
||||
"ALTER TABLE audio_settings ADD COLUMN pw_force_bitperfect INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN skip_sink_switch INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN allow_quality_fallback INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
@@ -178,7 +193,7 @@ impl AudioSettingsStore {
|
||||
pub fn get_settings(&self) -> Result<AudioSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect FROM audio_settings WHERE id = 1",
|
||||
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, skip_sink_switch, allow_quality_fallback FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
// Parse backend_type from JSON string
|
||||
@@ -215,6 +230,8 @@ impl AudioSettingsStore {
|
||||
normalization_target_lufs: row.get::<_, Option<f64>>(13)?.unwrap_or(-14.0) as f32,
|
||||
gapless_enabled: row.get::<_, Option<i64>>(14)?.unwrap_or(0) != 0,
|
||||
pw_force_bitperfect: row.get::<_, Option<i64>>(16)?.unwrap_or(0) != 0,
|
||||
skip_sink_switch: row.get::<_, Option<i64>>(17)?.unwrap_or(0) != 0,
|
||||
allow_quality_fallback: row.get::<_, Option<i64>>(18)?.unwrap_or(0) != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ description = "Casting support for QBZ - Chromecast, AirPlay, DLNA"
|
||||
|
||||
[dependencies]
|
||||
# Chromecast
|
||||
rust_cast = "0.18.1"
|
||||
rust_cast = "0.21"
|
||||
|
||||
# mDNS discovery
|
||||
mdns-sd = "0.11"
|
||||
|
||||
@@ -110,6 +110,31 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore a session from a saved OAuth user_auth_token.
|
||||
pub async fn login_with_token(&self, token: &str) -> Result<UserSession, CoreError> {
|
||||
let client = self.client.read().await;
|
||||
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
||||
|
||||
match client.login_with_token(token).await {
|
||||
Ok(session) => {
|
||||
self.emit(CoreEvent::LoggedIn {
|
||||
session: session.clone(),
|
||||
})
|
||||
.await;
|
||||
Ok(session)
|
||||
}
|
||||
Err(e) => {
|
||||
self.emit(CoreEvent::Error {
|
||||
code: "OAUTH_TOKEN_FAILED".to_string(),
|
||||
message: e.to_string(),
|
||||
recoverable: true,
|
||||
})
|
||||
.await;
|
||||
Err(CoreError::AuthFailed(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject an already-authenticated session (e.g. from OAuth flow).
|
||||
/// Emits a LoggedIn event so the rest of the system knows auth state changed.
|
||||
pub async fn set_session(&self, session: UserSession) -> Result<(), CoreError> {
|
||||
|
||||
@@ -263,10 +263,25 @@ impl MusicBrainzClient {
|
||||
}
|
||||
|
||||
/// Resolve an artist to get MusicBrainz ID
|
||||
///
|
||||
/// Prefers exact name matches over highest score to avoid disambiguation
|
||||
/// issues (e.g., multiple artists named "The Warning").
|
||||
pub async fn resolve_artist(&self, name: &str) -> IntegrationResult<Option<ResolvedArtist>> {
|
||||
let response = self.search_artist(name, 5).await?;
|
||||
let response = self.search_artist(name, 10).await?;
|
||||
|
||||
if let Some(artist) = response.artists.first() {
|
||||
if response.artists.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Prefer exact name match (case-insensitive)
|
||||
let target = name.trim().to_lowercase();
|
||||
let best = response
|
||||
.artists
|
||||
.iter()
|
||||
.find(|a| a.name.trim().to_lowercase() == target && a.score.unwrap_or(0) >= 90)
|
||||
.or_else(|| response.artists.first());
|
||||
|
||||
if let Some(artist) = best {
|
||||
let confidence = MatchConfidence::from_score(artist.score);
|
||||
|
||||
return Ok(Some(ResolvedArtist {
|
||||
@@ -651,6 +666,73 @@ impl MusicBrainzClient {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Resolve the country that an area belongs to by walking up the hierarchy.
|
||||
///
|
||||
/// Frankfurt am Main → Hessen → Germany → returns ("Germany", Some("de"))
|
||||
/// Monterrey → Nuevo León → Mexico → returns ("Mexico", Some("mx"))
|
||||
///
|
||||
/// Returns (country_name, country_code_lowercase) or None.
|
||||
pub async fn resolve_area_country(
|
||||
&self,
|
||||
area_id: &str,
|
||||
) -> IntegrationResult<Option<(String, Option<String>)>> {
|
||||
let mut current_id = area_id.to_string();
|
||||
let max_hops = 5;
|
||||
|
||||
for _hop in 0..max_hops {
|
||||
let detail = self.get_area_with_relations(¤t_id).await?;
|
||||
|
||||
// If current area IS a country, return it
|
||||
if detail
|
||||
.area_type
|
||||
.as_deref()
|
||||
.map(|t| t.eq_ignore_ascii_case("country"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let code = detail
|
||||
.iso_codes
|
||||
.as_ref()
|
||||
.and_then(|c| c.first())
|
||||
.map(|c| c.to_lowercase());
|
||||
return Ok(Some((detail.name, code)));
|
||||
}
|
||||
|
||||
// Find "part of" parent
|
||||
let parent = detail
|
||||
.relations
|
||||
.as_ref()
|
||||
.and_then(|rels| {
|
||||
rels.iter()
|
||||
.find(|rel| {
|
||||
rel.relation_type == "part of"
|
||||
&& rel.direction.as_deref() == Some("backward")
|
||||
})
|
||||
.and_then(|rel| rel.area.as_ref())
|
||||
});
|
||||
|
||||
match parent {
|
||||
Some(p) => {
|
||||
if p.area_type
|
||||
.as_deref()
|
||||
.map(|t| t.eq_ignore_ascii_case("country"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let code = p
|
||||
.iso_codes
|
||||
.as_ref()
|
||||
.and_then(|c| c.first())
|
||||
.map(|c| c.to_lowercase());
|
||||
return Ok(Some((p.name.clone(), code)));
|
||||
}
|
||||
current_id = p.id.clone();
|
||||
}
|
||||
None => return Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// ============ Internal Helpers ============
|
||||
|
||||
async fn check_enabled(&self) -> IntegrationResult<()> {
|
||||
|
||||
@@ -24,7 +24,7 @@ walkdir = "2"
|
||||
dirs = "5"
|
||||
|
||||
# Audio metadata
|
||||
lofty = "0.18"
|
||||
lofty = "0.23"
|
||||
|
||||
# Image processing (thumbnails)
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Metadata extraction for audio files
|
||||
|
||||
use lofty::{Accessor, AudioFile, ItemKey, Probe, TaggedFileExt};
|
||||
use lofty::prelude::*;
|
||||
use lofty::probe::Probe;
|
||||
use lofty::tag::ItemKey;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -452,7 +454,7 @@ impl MetadataExtractor {
|
||||
.or_else(|| fallback_artist.clone())
|
||||
.unwrap_or_else(|| "Unknown Artist".to_string()),
|
||||
album: album_title,
|
||||
album_artist: tag.get_string(&ItemKey::AlbumArtist).map(|s| s.to_string()),
|
||||
album_artist: tag.get_string(ItemKey::AlbumArtist).map(|s| s.to_string()),
|
||||
album_group_key,
|
||||
album_group_title,
|
||||
track_number: tag
|
||||
@@ -463,10 +465,10 @@ impl MetadataExtractor {
|
||||
.disk()
|
||||
.and_then(|d| if d > 0 { Some(d as u32) } else { None })
|
||||
.or(inferred_disc),
|
||||
year: tag.year().map(|y| y as u32),
|
||||
year: tag.date().map(|ts| ts.year as u32),
|
||||
genre: tag.genre().map(|s| s.to_string()),
|
||||
catalog_number: tag
|
||||
.get_string(&ItemKey::CatalogNumber)
|
||||
.get_string(ItemKey::CatalogNumber)
|
||||
.map(|s| s.to_string()),
|
||||
duration_secs,
|
||||
format,
|
||||
|
||||
@@ -465,8 +465,16 @@ fn try_init_stream_with_backend(
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
) -> Option<Result<StreamType, String>> {
|
||||
// Check if backend system is configured
|
||||
let backend_type = audio_settings.backend_type?;
|
||||
// Check if backend system is configured.
|
||||
// On non-Linux, default to SystemDefault when not explicitly set,
|
||||
// so macOS gets CoreAudio device probing and sample rate switching.
|
||||
let backend_type = audio_settings.backend_type.or_else(|| {
|
||||
if cfg!(not(target_os = "linux")) {
|
||||
Some(qbz_audio::AudioBackendType::SystemDefault)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})?;
|
||||
|
||||
log::info!(
|
||||
"Using backend system: {:?} (device: {:?}, plugin: {:?})",
|
||||
@@ -500,6 +508,7 @@ fn try_init_stream_with_backend(
|
||||
exclusive_mode: audio_settings.exclusive_mode,
|
||||
alsa_plugin: audio_settings.alsa_plugin,
|
||||
pw_force_bitperfect: audio_settings.pw_force_bitperfect,
|
||||
skip_sink_switch: audio_settings.skip_sink_switch,
|
||||
};
|
||||
|
||||
// For ALSA backend with hw: devices, try direct ALSA first (Linux only)
|
||||
@@ -602,6 +611,8 @@ pub struct SharedState {
|
||||
gapless_ready: Arc<AtomicBool>,
|
||||
/// Track ID of the gapless-queued next track (0 = none)
|
||||
gapless_next_track_id: Arc<AtomicU64>,
|
||||
/// Streaming buffer progress (0.0-1.0 stored as f32 bits, 0 = not streaming)
|
||||
buffer_progress: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl Default for SharedState {
|
||||
@@ -628,6 +639,7 @@ impl SharedState {
|
||||
normalization_gain: Arc::new(AtomicU32::new(0)),
|
||||
gapless_ready: Arc::new(AtomicBool::new(false)),
|
||||
gapless_next_track_id: Arc::new(AtomicU64::new(0)),
|
||||
buffer_progress: Arc::new(AtomicU32::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,6 +683,23 @@ impl SharedState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set streaming buffer progress (0.0 to 1.0). Pass 0.0 when not streaming.
|
||||
pub fn set_buffer_progress(&self, progress: f32) {
|
||||
self.buffer_progress
|
||||
.store(progress.to_bits(), Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Get streaming buffer progress (0.0 to 1.0). Returns None if not streaming.
|
||||
pub fn get_buffer_progress(&self) -> Option<f32> {
|
||||
let bits = self.buffer_progress.load(Ordering::SeqCst);
|
||||
let progress = f32::from_bits(bits);
|
||||
if progress <= 0.0 || progress >= 1.0 {
|
||||
None
|
||||
} else {
|
||||
Some(progress)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_current_device(&self, device: Option<String>) {
|
||||
if let Ok(mut d) = self.current_device.write() {
|
||||
*d = device;
|
||||
@@ -2314,6 +2343,14 @@ impl Player {
|
||||
if now.duration_since(last_empty_check) >= Duration::from_millis(500) {
|
||||
last_empty_check = now;
|
||||
|
||||
// Update streaming buffer progress for UI seekbar
|
||||
if let Some(streaming_src) = current_streaming_source.as_ref() {
|
||||
let progress = streaming_src.progress().unwrap_or(1.0);
|
||||
thread_state.set_buffer_progress(progress);
|
||||
} else {
|
||||
thread_state.set_buffer_progress(0.0);
|
||||
}
|
||||
|
||||
// Streaming -> cached promotion:
|
||||
// once streaming download completes, persist full data and clear streaming marker.
|
||||
// This unlocks normal gapless pre-queue for the current track's tail.
|
||||
|
||||
@@ -276,6 +276,62 @@ impl QobuzClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore a session from a previously saved OAuth user_auth_token.
|
||||
///
|
||||
/// Used at startup when the user logged in via OAuth (web browser) and the
|
||||
/// token was persisted. Calls POST /user/login with X-User-Auth-Token header.
|
||||
/// Returns an error if the token has expired.
|
||||
pub async fn login_with_token(&self, token: &str) -> Result<UserSession> {
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
|
||||
let tokens = self.tokens.read().await;
|
||||
let app_id = tokens
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))?
|
||||
.app_id
|
||||
.clone();
|
||||
drop(tokens);
|
||||
|
||||
let user_login_url = endpoints::build_url(endpoints::paths::USER_LOGIN);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"X-App-Id",
|
||||
HeaderValue::from_str(&app_id).map_err(|_| ApiError::InvalidAppId)?,
|
||||
);
|
||||
headers.insert(
|
||||
"X-User-Auth-Token",
|
||||
HeaderValue::from_str(token)
|
||||
.map_err(|_| ApiError::AuthenticationError("Invalid token format".into()))?,
|
||||
);
|
||||
|
||||
log::info!("[OAuth] Restoring session from saved token");
|
||||
let resp = self
|
||||
.http
|
||||
.post(&user_login_url)
|
||||
.headers(headers)
|
||||
.header("Content-Type", "text/plain;charset=UTF-8")
|
||||
.body("extra=partner")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
match resp.status() {
|
||||
StatusCode::OK => {
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
let session = super::auth::parse_login_response(&json)?;
|
||||
*self.session.write().await = Some(session.clone());
|
||||
log::info!("[OAuth] Session restored from token");
|
||||
Ok(session)
|
||||
}
|
||||
StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationError(
|
||||
"OAuth token expired or invalid".to_string(),
|
||||
)),
|
||||
status => Err(ApiError::ApiResponse(format!(
|
||||
"Token re-auth failed: {}",
|
||||
status
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current user info (display name, subscription, and expiry if available)
|
||||
pub async fn get_user_info(&self) -> Option<(String, String, Option<String>)> {
|
||||
self.session.read().await.as_ref().map(|s| {
|
||||
|
||||
@@ -1015,13 +1015,6 @@ fn queue_version_opt(
|
||||
Ok(Some(QueueVersion::new(major, minor)))
|
||||
}
|
||||
|
||||
fn required_i32_to_u64(value: Option<i32>, field_name: &str) -> Result<u64, ProtocolError> {
|
||||
let raw = value.ok_or_else(|| {
|
||||
ProtocolError::InvalidPayload(format!("missing required numeric field '{field_name}'"))
|
||||
})?;
|
||||
i32_to_u64(raw)
|
||||
}
|
||||
|
||||
fn optional_i32_to_u64_named(
|
||||
value: Option<i32>,
|
||||
field_name: &str,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
ea2280f8 chore: update codename to Bye bye plain auth
|
||||
ee019851 chore: bump version to 1.2.2
|
||||
e66117fb feat(auth): remove basic auth login, OAuth only
|
||||
adfc3900 fix(notifications): use released notify-rust 4.13.0 instead of fork
|
||||
7a1890ad fix(audio): use released coreaudio-rs 0.14.1 instead of fork
|
||||
b1000cb9 fix(macos): gate Linux graphics CLI flags and crash handler with cfg
|
||||
34a18093 fix: restore albumId/artistId on session resume for player bar navigation
|
||||
3ac7073b feat(qobuz): migrate login_with_token to V2 crates
|
||||
211f45a5 fix(favorites): bulk make offline now actually downloads tracks
|
||||
ba3ccf9a feat(settings): hide tray sub-settings on macOS and use menu bar terminology
|
||||
7edffcf5 feat(i18n): add macOS-specific menu bar tray keys for all locales
|
||||
84b1f1a5 docs: remove Apple Silicon only note from macOS section
|
||||
833cc2b3 fix: boost immersive drag region z-index on macOS
|
||||
8283bec6 feat: add Cmd+, keyboard shortcut to open settings
|
||||
35d0ff39 chore: remove unused github.svg (replaced by simple-icons in PR #237)
|
||||
b8af1763 feat(macos): add album artwork to notifications via image_path
|
||||
a4a591e7 feat(macos): switch to notify-rust fork with image_path support
|
||||
684dd558 fix(macos): scope title bar padding to non-home views only
|
||||
9823dfb6 fix(macos): enable x86_64 cross-compilation
|
||||
071865e7 fix: replace removed Github icon with simple-icons
|
||||
cd2472f5 fix(macos): add top padding to clear native overlay title bar
|
||||
b6ad1926 fix(macos): hide Linux-only settings on macOS
|
||||
47835305 fix(macos): gate idle_inhibit module for Linux only
|
||||
1e019bec fix: resolve correct artist and country for MusicBrainz metadata
|
||||
1953774d fix: resolve birth country from area hierarchy instead of MB country field
|
||||
305cffa0 fix: don't append country to birth location display
|
||||
f702622e fix: correct artist birth/formation location display order
|
||||
d19c142d fix: switch log upload to paste.rs (dpaste.org returns 403/405)
|
||||
527d63ca fix: migrate log upload from 0x0.st to dpaste.org
|
||||
a87e35d1 fix: restore MPRIS metadata and playback context on session resume
|
||||
6acd8438 feat: add streaming buffer progress indicator to seekbar
|
||||
18403bba fix: single ESC exits both immersive overlay and window fullscreen
|
||||
b315b23b fix: F11 fullscreen toggle and ESC exit work globally
|
||||
759149f3 feat(favorites): add bulk make-offline action to track selection bar
|
||||
93374080 feat(titlebar): add Playlists to favorites dropdown in titlebar nav
|
||||
b00f294e feat(sidebar): add Playlists to favorites quick access menu
|
||||
080b916c feat: prevent system sleep during playback via XDG portal
|
||||
2900fb88 feat: add log sanitization for sensitive IDs and UUIDs
|
||||
4e526b1a chore(deps): upgrade vite 6->8, vite-plugin-svelte 5->7, kit 2.55
|
||||
a99d558e fix: update lofty 0.18 API calls to lofty 0.23
|
||||
166d3467 fix: update axum 0.7 API calls to axum 0.8
|
||||
a2fb4451 feat(macos): add deep link support for qobuzapp:// URLs
|
||||
66a0c8f0 fix: update rand 0.8 API calls to rand 0.10
|
||||
00cf8cf4 refactor(icons): rename all deprecated lucide-svelte icons to v1 names
|
||||
6bf397e0 feat(macos): auto-select SystemDefault backend on non-Linux
|
||||
4c15a432 feat(macos): enhance CpalDefaultBackend with device capabilities
|
||||
c51f07fe feat(macos): add CoreAudio device probing and sample rate switching
|
||||
84c9b693 style: fix indentation and missing semicolon from PR #230
|
||||
38258ec9 fix(i18n): post-merge corrections for PR #226
|
||||
b6cebe26 feat(i18n): add translation strings to AboutModal
|
||||
0549a701 feat(i18n): add french translation strings
|
||||
67b8bf3c feat(i18n): add spanish translation strings
|
||||
bc126341 feat(i18n): add english translation strings
|
||||
881df48f feat(i18n): add translation strings to SettingsView
|
||||
d97e8f86 feat(i18n): add translation strings to LocalLibraryView
|
||||
28cf84a4 feat(i18n): translation strings in Sidebar
|
||||
64061176 feat(i18n): translation strings in SearchView
|
||||
38efc967 fix(i18n): middot in TopQView
|
||||
935cf34c fix(i18n): date formatting in PurchasesView
|
||||
34c6e413 fix(i18n): date formatting in PurchaseAlbumDetailView
|
||||
0f0dfda1 fix(i18n): date formatting in BlacklistManagerView
|
||||
17babe48 fix(i18n): date formatting in ArtistDetailView
|
||||
40801dc7 feat(i18n): add translation strings to PlaylistmanagerView
|
||||
fafd5473 Fix scroll position not being saved when navigating without scrolling
|
||||
b7068f3d feat(i18n): add translation strings to MusicianPageView
|
||||
95daf91d feat(i18n): add translation strings to LoginView
|
||||
82d81352 feat(i18n): add translation strings to LabelView
|
||||
868a6bdf fix(i18n): CD quality on HomeView
|
||||
55b7d97a feat(i18n): add translation strings to ForYouTab
|
||||
656866cd feat(i18n): add translation strings to FavQView
|
||||
b2bd4a2a feat(i18n): add translation strings to DynamicSuggestView
|
||||
37de089c feat(i18n): add translation strings to DiscoverBrowseView
|
||||
eb7a6a9a feat(i18n): add translation strings to BlacklistManagerView
|
||||
55bc56ae fix(i18n): title sort on artist master-detail view
|
||||
46943be4 Fix scroll restoration for virtualized containers
|
||||
b879a8f9 feat(i18n): adjustments on ArtistsByLocationView
|
||||
cddb98ed feat(i18n): add english translations
|
||||
d6a4b11a feat(i18n): add french translations
|
||||
c0a29020 feat(i18n): add strings to FavoritesView
|
||||
2e450fec feat(i18n): adjustments on artist detailed view
|
||||
5609cc1c feat(i18n): album scrolling prev and next strings
|
||||
18f99c7b feat(i18n): localization for CD Quality on quality badge
|
||||
e4b98223 feat(i18n): localized release date on album credits
|
||||
d6eedb7d feat(i18n): add language strings for artist page
|
||||
0082f474 feat(i18n): add translation strings for CD Quality
|
||||
b935b836 feat(i18n): add localization to album release dates
|
||||
4a7cc604 feat(i18n): add translation strings in ArtistDetailView
|
||||
1c51eac5 docs: add contributors section to README, remove inline credits
|
||||
e34212a5 docs: add macOS experimental section to README
|
||||
88c352f6 fix(gentoo): use libayatana-appindicator instead of libappindicator
|
||||
@@ -0,0 +1,70 @@
|
||||
# 1.2.2 — Bye bye plain auth
|
||||
|
||||
About a month and a half ago, Qobuz began making changes to their authentication system. This was the reason we introduced OAuth login early on — and fortunately, doing so ahead of time meant no one was truly locked out of the app. This release simply removes direct credential login, making OAuth the sole authentication method. There is also some bugfixing for issues reported on GitHub.
|
||||
|
||||
We are considering requesting official API access again, and looking for ways to coordinate with the community to support this request to Qobuz — so that a more severe change in the future does not leave us out.
|
||||
|
||||
Version 1.2.2 was supposed to take a little longer to arrive, with initial support for BSD and headless mode, but this authentication issue has forced us to bring it forward; there aren't really many new features this time.
|
||||
|
||||
Thank you all for the continued support for QBZ.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
- **OAuth-only login** — username/password authentication has been removed; OAuth via browser is now the sole login method
|
||||
- **System browser login** — new option to authenticate via your default browser (Firefox, Chrome, etc.) instead of the embedded WebView; supports password managers and saved sessions
|
||||
- **Bootstrap no longer attempts basic auth** — users with old saved credentials now see the login screen instead of hitting Qobuz's deprecated endpoint
|
||||
- **Token login migrated to V2 crates** — OAuth token handling moved to the V2 core architecture
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **"Go to Album" / "Go to Artist" broken after launch** — navigation from the player bar now works without starting new playback first (#252)
|
||||
- **MPRIS metadata missing on session restore** — playback context and metadata now populate correctly on startup (#240)
|
||||
- **Incorrect artist location in Artist Network** — birth/formation location display fixed for MusicBrainz area hierarchy (#235)
|
||||
- **Bulk "make available offline" not downloading** — the action now actually triggers track downloads (#231)
|
||||
- **F11 fullscreen and ESC exit** — both now work globally across all views (#202)
|
||||
- **Cached tracks playing at wrong quality** — cache now validates sample rate and bit depth against the requested quality; stale fallback-quality files are re-downloaded transparently (#270)
|
||||
- **JACK/qjackctl routing broken between tracks** — new "Lock Output Device" option prevents QBZ from changing the system default sink on stream recreation (#263)
|
||||
- **Scroll position lost on navigation** — scroll state is now saved even without explicit scrolling
|
||||
- **Virtualized container scroll restoration** — fixed for all virtualized list and grid components
|
||||
|
||||
## New Features
|
||||
|
||||
- **Playlists in favorites quick access** — added to both the sidebar menu and the titlebar dropdown (#233)
|
||||
- **Bulk make-offline action** — select multiple tracks and download them all at once from the selection bar (#231)
|
||||
- **Sleep inhibition** — system sleep is now prevented during active playback via XDG portal (#229)
|
||||
- **Log sanitization** — sensitive IDs and UUIDs are now stripped from logs before upload (#213)
|
||||
- **Streaming buffer indicator** — progress bar in the seekbar shows how much of the track has been buffered (#194)
|
||||
- **Lock Output Device** — opt-in PipeWire setting for JACK/DAW users; skips pactl set-default-sink to preserve external routing (#263)
|
||||
|
||||
## macOS Improvements
|
||||
|
||||
- **CoreAudio device probing** — sample rate switching and device capability detection
|
||||
- **Deep link support** — qobuzapp:// URLs handled natively
|
||||
- **Rich notifications** — album artwork in desktop notifications via image_path
|
||||
- **Platform-aware settings** — Linux-only options hidden on macOS; menu bar terminology for tray settings
|
||||
- **Cmd+, shortcut** — opens settings (standard macOS convention)
|
||||
- **Graphics CLI flags gated** — --autoconfig-graphics, --reset-graphics, --reset-dmabuf are Linux-only
|
||||
- **Released upstream deps** — coreaudio-rs 0.14.1 and notify-rust 4.13.0 replace git forks
|
||||
- **x86_64 cross-compilation** — macOS builds now support Intel Macs
|
||||
|
||||
## i18n
|
||||
|
||||
- **20+ views and components** — hardcoded strings replaced with translation keys
|
||||
- **Date formatting fixes** — corrected across ArtistDetailView, PurchasesView, BlacklistManagerView, and PurchaseAlbumDetailView
|
||||
- **CD quality label** — localized across all badge and quality displays
|
||||
- **macOS-specific keys** — menu bar terminology for all 5 locales
|
||||
|
||||
## Other
|
||||
|
||||
- **Log upload migrated to paste.rs** — dpaste.org now returns 403/405
|
||||
- **Gentoo overlay** — use libayatana-appindicator instead of libappindicator (#262)
|
||||
- **Security** — esbuild upgraded to 0.25.x (CVE in <= 0.24.2); CodeQL cleartext-logging alerts resolved
|
||||
- **Dependency updates** — vite 8, lofty 0.23, axum 0.8, rand 0.10, souvlaki 0.8.3, lucide-svelte 1.0
|
||||
|
||||
---
|
||||
|
||||
Thanks to @afonsojramos, @GwendalBeaumont, and @AdamArstall for their contributions to this release.
|
||||
|
||||
Full changelog: https://github.com/vicrodh/qbz/compare/v1.2.1...v1.2.2
|
||||
@@ -15,7 +15,7 @@
|
||||
# VERSION BUMP: update version, rev, and hashes
|
||||
# when tagging a new release.
|
||||
# ──────────────────────────────────────────────
|
||||
qbzVersion = "1.2.1";
|
||||
qbzVersion = "1.2.2";
|
||||
qbzRev = "v${qbzVersion}";
|
||||
srcHash = ""; # nix build will report the correct hash on first run
|
||||
npmHash = ""; # nix build will report the correct hash on first run
|
||||
|
||||
Generated
+611
-412
File diff suppressed because it is too large.
Load diff
+12
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "qbz-nix",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"description": "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -26,28 +26,30 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@icons-pack/svelte-simple-icons": "^7.2.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.5.0",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-svelte": "^0.562.0",
|
||||
"@tauri-apps/plugin-os": "^2",
|
||||
"lucide-svelte": "^1.0.1",
|
||||
"svelte-i18n": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.52.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||
"@sveltejs/kit": "^2.55.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"svelte": "^5.54.0",
|
||||
"svelte-check": "^4.4.5",
|
||||
"svelte": "^5.55.0",
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.1.0"
|
||||
"vite": "^8.0.3",
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "^0.25.0",
|
||||
"cookie": "^0.7.0"
|
||||
"cookie": "^0.7.0",
|
||||
"esbuild": "^0.25.0"
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,26 @@
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="1.2.2" date="2026-04-04">
|
||||
<description>
|
||||
<p>About a month and a half ago, Qobuz began making changes to their authentication system. This was the reason we introduced OAuth login early on — and fortunately, doing so ahead of time meant no one was truly locked out of the app. This release simply removes direct credential login, making OAuth the sole authentication method. There is also some bugfixing for issues reported on GitHub.</p>
|
||||
<p>We are considering requesting official API access again, and looking for ways to coordinate with the community to support this request to Qobuz — so that a more severe change in the future does not leave us out. Thank you all for the continued support for QBZ.</p>
|
||||
<ul>
|
||||
<li>OAuth is now the only login method; username/password authentication has been removed</li>
|
||||
<li>Fixed "Go to Album" and "Go to Artist" navigation failing until starting new playback (#252)</li>
|
||||
<li>Fixed MPRIS metadata not populating on session restore (#240)</li>
|
||||
<li>Fixed incorrect artist birth/formation location display in Artist Network (#235)</li>
|
||||
<li>Added Playlists to the favorites quick access menu and titlebar dropdown (#233)</li>
|
||||
<li>Added bulk "make available offline" action to track selection bar (#231)</li>
|
||||
<li>System sleep is now prevented during active playback via XDG portal (#229)</li>
|
||||
<li>Sensitive IDs and UUIDs are now sanitized from logs (#213)</li>
|
||||
<li>F11 fullscreen toggle and ESC exit now work globally (#202)</li>
|
||||
<li>Added streaming buffer progress indicator to the seekbar (#194)</li>
|
||||
<li>Initial macOS support: CoreAudio device probing, deep links, rich notifications, platform-aware settings</li>
|
||||
<li>Dependency updates: coreaudio-rs, notify-rust, axum, lofty, rand, souvlaki, lucide-svelte, vite</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.2.1" date="2026-03-25">
|
||||
<description>
|
||||
<p>Resilience: exponential backoff retry (1s/2s/4s) with quality fallback when Qobuz servers return 504s. User preference modal for retry behavior (ask/always fallback/always skip), persisted in settings. Degraded service indicator with 5-minute auto-clear. Manual network check button. macOS port foundation (gated backends, native title bar, CI workflow). Offline mode improvements (last known profile restore, dismissible banner, network folders). WebKitGTK 2.50+ image flicker fix. Hybrid Intel+NVIDIA compositing fix. QConnect fixes for queue_version tolerance and ALSA volume control. i18n: hardcoded strings replaced in SearchView and PlaylistDetailView. Security: aws-lc-sys 0.39.0, rustls-webpki 0.103.10.</p>
|
||||
|
||||
@@ -3,21 +3,19 @@
|
||||
|
||||
EAPI=8
|
||||
|
||||
inherit cargo xdg
|
||||
inherit xdg
|
||||
|
||||
DESCRIPTION="Native hi-fi Qobuz desktop player for Linux"
|
||||
HOMEPAGE="https://qbz.lol https://github.com/vicrodh/qbz"
|
||||
|
||||
MY_PV="${PV}"
|
||||
SRC_URI="
|
||||
https://github.com/vicrodh/qbz/archive/refs/tags/v${MY_PV}.tar.gz -> ${P}.tar.gz
|
||||
${CARGO_CRATE_URIS}
|
||||
"
|
||||
SRC_URI="https://github.com/vicrodh/qbz/archive/refs/tags/v${MY_PV}.tar.gz -> ${P}.tar.gz"
|
||||
|
||||
LICENSE="MIT"
|
||||
SLOT="0"
|
||||
KEYWORDS="~amd64 ~arm64"
|
||||
IUSE="pipewire pulseaudio"
|
||||
RESTRICT="mirror network-sandbox"
|
||||
|
||||
RDEPEND="
|
||||
net-libs/webkit-gtk:4.1
|
||||
@@ -28,7 +26,7 @@ RDEPEND="
|
||||
x11-libs/libxkbcommon
|
||||
gnome-base/librsvg:2
|
||||
pipewire? (
|
||||
media-video/pipewire[alsa-plugin]
|
||||
media-video/pipewire[pipewire-alsa]
|
||||
)
|
||||
pulseaudio? (
|
||||
media-libs/libpulse
|
||||
@@ -36,49 +34,36 @@ RDEPEND="
|
||||
"
|
||||
|
||||
BDEPEND="
|
||||
virtual/rust
|
||||
dev-lang/rust
|
||||
net-libs/nodejs[npm]
|
||||
sys-devel/clang
|
||||
llvm-core/clang
|
||||
virtual/pkgconfig
|
||||
"
|
||||
|
||||
# alsa-utils improves device detection for bit-perfect playback
|
||||
PDEPEND="
|
||||
media-sound/alsa-utils
|
||||
"
|
||||
|
||||
S="${WORKDIR}/qbz-${MY_PV}"
|
||||
|
||||
src_prepare() {
|
||||
default
|
||||
|
||||
# Generate cargo config for offline build if needed
|
||||
cargo_src_prepare
|
||||
}
|
||||
|
||||
src_configure() {
|
||||
cargo_src_configure
|
||||
}
|
||||
|
||||
src_compile() {
|
||||
# Install npm dependencies (network required)
|
||||
npm ci || die "npm ci failed"
|
||||
|
||||
# Build the SvelteKit frontend
|
||||
npm ci --prefer-offline || die "npm ci failed"
|
||||
npm run build || die "frontend build failed"
|
||||
|
||||
# Build the Tauri/Rust backend (no bundler — we install manually)
|
||||
# Build the Tauri/Rust backend
|
||||
cd src-tauri || die
|
||||
cargo_src_compile --bin qbz
|
||||
cargo build --release --bin qbz || die "cargo build failed"
|
||||
}
|
||||
|
||||
src_install() {
|
||||
# Install the binary
|
||||
dobin "src-tauri/target/release/qbz"
|
||||
|
||||
# Desktop file
|
||||
insinto /usr/share/applications
|
||||
newins packaging/arch/qbz.desktop qbz.desktop
|
||||
|
||||
# Icons
|
||||
local size
|
||||
for size in 32 48 64 128 256; do
|
||||
local src_icon="src-tauri/icons/${size}x${size}.png"
|
||||
@@ -88,7 +73,6 @@ src_install() {
|
||||
fi
|
||||
done
|
||||
|
||||
# Use the high-res icon as a fallback for missing sizes
|
||||
local fallback="src-tauri/icons/128x128.png"
|
||||
for size in 48 64; do
|
||||
if [[ ! -f "src-tauri/icons/${size}x${size}.png" ]]; then
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
name: qbz-player
|
||||
title: "QBZ — Native Qobuz Client for Linux"
|
||||
version: '1.2.1'
|
||||
version: '1.2.2'
|
||||
summary: Native Qobuz player for Linux with bit-perfect audio and DAC control
|
||||
description: |
|
||||
QBZ is a native Linux desktop client for Qobuz™, designed for high-fidelity playback.
|
||||
|
||||
Generated
+340
-78
@@ -124,9 +124,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
@@ -145,9 +145,9 @@ checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
@@ -230,6 +230,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"getrandom 0.4.2",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"zbus",
|
||||
]
|
||||
@@ -463,14 +464,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
@@ -483,15 +484,14 @@ dependencies = [
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-tungstenite 0.28.0",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -500,19 +500,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -929,6 +927,26 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -1077,6 +1095,20 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16dd574a72a021b90c7656c474ea31d11a2f0366a8eff574186e761e0b9e3586"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
"objc2-audio-toolbox",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.17.1"
|
||||
@@ -1084,7 +1116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
|
||||
dependencies = [
|
||||
"alsa 0.10.0",
|
||||
"coreaudio-rs",
|
||||
"coreaudio-rs 0.13.0",
|
||||
"dasp_sample",
|
||||
"jni",
|
||||
"js-sys",
|
||||
@@ -1438,6 +1470,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||
dependencies = [
|
||||
"const-random",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
@@ -1593,9 +1634,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.9"
|
||||
version = "0.11.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
|
||||
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -2596,7 +2637,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
"windows-registry 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2759,9 +2800,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.9"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
@@ -2770,8 +2811,8 @@ dependencies = [
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
"tiff",
|
||||
"zune-core 0.5.1",
|
||||
"zune-jpeg 0.5.12",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3160,9 +3201,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lofty"
|
||||
version = "0.18.2"
|
||||
version = "0.23.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f75066eb1d25a7047fb2667edb410ae2592439ed81546f95c28b0a1c7d7d3818"
|
||||
checksum = "6d0c107dba5af049cf1c36b646fc1ba0cd2705f40d766d2c4c64f1b797c5fbed"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"data-encoding",
|
||||
@@ -3175,9 +3216,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lofty_attr"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "764b60e1ddd07e5665a6a17636a95cd7d8f3b86c73503a69c32979d05f72f3cf"
|
||||
checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3265,9 +3306,9 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
@@ -3363,9 +3404,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.7.11"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
@@ -3471,6 +3512,18 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodrop"
|
||||
version = "0.1.14"
|
||||
@@ -3498,9 +3551,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.12.0"
|
||||
version = "4.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2"
|
||||
checksum = "9612133a804b4bc753f9f806de73fc9730b7694eb1bada2dc252cce022638be8"
|
||||
dependencies = [
|
||||
"futures-lite",
|
||||
"log",
|
||||
@@ -3656,6 +3709,17 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio"
|
||||
version = "0.3.2"
|
||||
@@ -3679,6 +3743,16 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
@@ -3705,6 +3779,38 @@ dependencies = [
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-image"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-location"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -3763,8 +3869,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-location",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"objc2-user-notifications",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-user-notifications"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
@@ -3784,9 +3909,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ogg_pager"
|
||||
version = "0.6.1"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87b0bef808533c5890ab77279538212efdbbbd9aa4ef1ccdfcfbf77a42f7e6fa"
|
||||
checksum = "6d6d1ca8364b84e0cf725eed06b1460c44671e6c0fb28765f5262de3ece07fdc"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
@@ -3880,6 +4005,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||
dependencies = [
|
||||
"dlv-list",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
@@ -3890,6 +4025,22 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_info"
|
||||
version = "3.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"log",
|
||||
"nix",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-ui-kit",
|
||||
"serde",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
@@ -4419,9 +4570,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "3.2.0"
|
||||
version = "3.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b55bad9126f378a853655831eb7363b7b01b81d19f8cb1218861086ca4a1a61e"
|
||||
checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"protobuf-support",
|
||||
@@ -4430,9 +4581,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "protobuf-codegen"
|
||||
version = "3.2.0"
|
||||
version = "3.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dd418ac3c91caa4032d37cb80ff0d44e2ebe637b2fb243b6234bf89cdac4901"
|
||||
checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"once_cell",
|
||||
@@ -4445,12 +4596,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "protobuf-parse"
|
||||
version = "3.2.0"
|
||||
version = "3.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d39b14605eaa1f6a340aec7f320b34064feb26c93aec35d6a9a2272a8ddfa49"
|
||||
checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.13.0",
|
||||
"log",
|
||||
"protobuf",
|
||||
"protobuf-support",
|
||||
@@ -4461,9 +4612,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "protobuf-support"
|
||||
version = "3.2.0"
|
||||
version = "3.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5d4d7b8601c814cfb36bcebb79f0e61e45e1e93640cf778837833bbed05c372"
|
||||
checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6"
|
||||
dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
@@ -4496,6 +4647,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"alsa 0.9.1",
|
||||
"async-trait",
|
||||
"coreaudio-rs 0.14.1",
|
||||
"dirs 5.0.1",
|
||||
"ebur128",
|
||||
"log",
|
||||
@@ -4598,7 +4750,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "qbz-nix"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"alsa 0.9.1",
|
||||
@@ -4638,7 +4790,7 @@ dependencies = [
|
||||
"qconnect-core",
|
||||
"qconnect-transport-ws",
|
||||
"qrcode-generator",
|
||||
"rand 0.8.5",
|
||||
"rand 0.10.0",
|
||||
"rcgen",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
@@ -4654,8 +4806,10 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-os",
|
||||
"tauri-plugin-single-instance",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
@@ -4755,7 +4909,7 @@ dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-tungstenite 0.24.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5307,19 +5461,30 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_cast"
|
||||
version = "0.18.1"
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c9b210a559ed66ebb5096ebb754ad14e197bf8954c029bae6bfc82f0f51e460"
|
||||
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_cast"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "235d09067a35143d271fdeaf28298b4f729b5da50abdbf3b9e6395262d8bc7a9"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"log",
|
||||
"openssl",
|
||||
"protobuf",
|
||||
"protobuf-codegen",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5393,6 +5558,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -5401,6 +5567,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
@@ -5880,10 +6058,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "souvlaki"
|
||||
version = "0.7.3"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea4544ba17df4ac03d6503ae8abba19adad3ae89203a425945dc4c12d7790bfa"
|
||||
checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"block",
|
||||
"cocoa",
|
||||
"core-graphics 0.22.3",
|
||||
@@ -6231,6 +6410,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
@@ -6469,6 +6657,27 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"plist",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"url",
|
||||
"windows-registry 0.5.3",
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
@@ -6531,6 +6740,24 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-os"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"log",
|
||||
"os_info",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serialize-to-javascript",
|
||||
"sys-locale",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.0"
|
||||
@@ -6540,6 +6767,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin-deep-link",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
@@ -6660,9 +6888,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.26.0"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
@@ -6724,16 +6952,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.10.3"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
|
||||
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error",
|
||||
"weezl",
|
||||
"zune-jpeg 0.4.21",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6767,6 +6995,15 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny_http"
|
||||
version = "0.12.0"
|
||||
@@ -6877,10 +7114,22 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"tungstenite 0.24.0",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite 0.28.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -7153,6 +7402,23 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -7314,9 +7580,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -7936,6 +8202,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
@@ -8748,34 +9025,19 @@ version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.4.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
|
||||
dependencies = [
|
||||
"zune-core 0.4.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe"
|
||||
dependencies = [
|
||||
"zune-core 0.5.1",
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+18
-18
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "qbz-nix"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
description = "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)"
|
||||
authors = ["blitzkriegfc"]
|
||||
edition = "2021"
|
||||
@@ -34,7 +34,9 @@ qconnect-transport-ws = { path = "../crates/qconnect-transport-ws" }
|
||||
# Tauri
|
||||
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "macos-private-api"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-os = "2"
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
tauri-plugin-deep-link = "2"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -49,7 +51,7 @@ async-trait = "0.1"
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
|
||||
# Local API server
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
axum-server = { version = "0.8", features = ["tls-rustls"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] }
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
@@ -78,7 +80,7 @@ log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
# Random number generation (for shuffle)
|
||||
rand = "0.8"
|
||||
rand = "0.10"
|
||||
|
||||
# Audio playback
|
||||
rodio = { version = "0.22", features = ["symphonia-all"] }
|
||||
@@ -91,7 +93,7 @@ spectrum-analyzer = "1.5"
|
||||
ebur128 = "0.1"
|
||||
|
||||
# Media controls (MPRIS on Linux)
|
||||
souvlaki = "0.7"
|
||||
souvlaki = "0.8"
|
||||
|
||||
# Desktop notifications → ashpd in [target.linux] deps below
|
||||
|
||||
@@ -99,23 +101,20 @@ souvlaki = "0.7"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
|
||||
# PDF rendering with MuPDF engine (high-quality rasterization for booklet viewer)
|
||||
# NOTE: Use sys-lib-* for zlib/harfbuzz/freetype to avoid symbol conflicts with
|
||||
# GTK/GdkPixbuf. But NOT libjpeg — Ubuntu ships libjpeg8 (libjpeg.so.8) while
|
||||
# Debian/Fedora ship libjpeg62-turbo (libjpeg.so.62). Let mupdf vendor its own
|
||||
# libjpeg to avoid cross-distro breakage (GitHub #161, #162).
|
||||
mupdf = { version = "0.6", default-features = false, features = [
|
||||
"system-fonts",
|
||||
"sys-lib-zlib",
|
||||
"sys-lib-harfbuzz",
|
||||
"sys-lib-freetype",
|
||||
] }
|
||||
# NOTE: On Linux, use sys-lib-* for zlib/harfbuzz/freetype to avoid symbol
|
||||
# conflicts with GTK/GdkPixbuf. But NOT libjpeg — Ubuntu ships libjpeg8 while
|
||||
# Debian/Fedora ship libjpeg62-turbo. Let mupdf vendor its own libjpeg to avoid
|
||||
# cross-distro breakage (GitHub #161, #162).
|
||||
# On macOS, let mupdf vendor everything (no GTK conflicts, and system libs
|
||||
# don't support x86_64 cross-compilation from Apple Silicon).
|
||||
mupdf = { version = "0.6", default-features = false, features = ["system-fonts"] }
|
||||
|
||||
# URL opening
|
||||
open = "5"
|
||||
|
||||
# Local library
|
||||
walkdir = "2"
|
||||
lofty = "0.18"
|
||||
lofty = "0.23"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
dirs = "5"
|
||||
filetime = "0.2"
|
||||
@@ -145,14 +144,15 @@ futures-util = "0.3"
|
||||
# Platform-specific dependencies
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
tauri-plugin-dialog = { version = "2.6.0" }
|
||||
ashpd = { version = "0.13", default-features = false, features = ["tokio", "notification"] }
|
||||
ashpd = { version = "0.13", default-features = false, features = ["tokio", "notification", "inhibit"] }
|
||||
mupdf = { version = "0.6", features = ["sys-lib-zlib", "sys-lib-harfbuzz", "sys-lib-freetype"] }
|
||||
|
||||
# Direct ALSA access for bit-perfect playback (bypasses CPAL limitations)
|
||||
alsa = "0.9"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
tauri-plugin-dialog = { version = "2.6.0" }
|
||||
notify-rust = "4"
|
||||
notify-rust = "4.13.0"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
tauri-plugin-dialog = { version = "2.6.0" }
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"core:webview:allow-webview-close",
|
||||
"core:webview:allow-set-webview-zoom",
|
||||
"opener:default",
|
||||
"os:default",
|
||||
"dialog:default",
|
||||
"clipboard-manager:default",
|
||||
"clipboard-manager:allow-write-text"
|
||||
|
||||
@@ -505,8 +505,8 @@ fn build_router(ctx: ApiContext, allowed_origins: Vec<String>) -> Router {
|
||||
.route("/api/favorites/add", post(add_favorite))
|
||||
.route("/api/favorites/remove", post(remove_favorite))
|
||||
.route("/api/album/play", post(play_album))
|
||||
.route("/api/album/:id", get(get_album))
|
||||
.route("/api/artist/:id", get(get_artist))
|
||||
.route("/api/album/{id}", get(get_album))
|
||||
.route("/api/artist/{id}", get(get_artist))
|
||||
.route("/api/playback/preferences", get(get_playback_preferences))
|
||||
.route("/api/playback/autoplay", post(set_autoplay))
|
||||
.route("/api/ws", get(ws_handler))
|
||||
@@ -1061,7 +1061,7 @@ async fn handle_ws(mut socket: WebSocket, mut rx: broadcast::Receiver<PlaybackEv
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if socket.send(Message::Text(payload)).await.is_err() {
|
||||
if socket.send(Message::Text(payload.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,7 +447,7 @@ impl SuggestionsEngine {
|
||||
|
||||
// 6. Shuffle tracks for variety (so same artist doesn't dominate)
|
||||
use rand::seq::SliceRandom;
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut rng = rand::rng();
|
||||
all_tracks.shuffle(&mut rng);
|
||||
|
||||
// 7. Limit pool size
|
||||
|
||||
+478
-548
File diff suppressed because it is too large.
Load diff
@@ -55,6 +55,13 @@ pub struct AudioSettings {
|
||||
/// Values: "ask" (default), "always_fallback", "always_skip"
|
||||
/// Protected by ADR-003: must survive reset_all() and migrations.
|
||||
pub quality_fallback_behavior: String,
|
||||
/// When true, skip `pactl set-default-sink` on stream creation.
|
||||
/// Preserves external routing (JACK, qjackctl, Reaper).
|
||||
/// Mutually exclusive with dac_passthrough.
|
||||
pub skip_sink_switch: bool,
|
||||
/// When true, automatically try lower quality tiers if the requested one fails.
|
||||
/// When false (default), playback or download fails if the exact quality is unavailable.
|
||||
pub allow_quality_fallback: bool,
|
||||
}
|
||||
|
||||
impl Default for AudioSettings {
|
||||
@@ -79,6 +86,8 @@ impl Default for AudioSettings {
|
||||
pw_force_bitperfect: false, // Off by default — experimental PipeWire feature
|
||||
sync_audio_on_startup: false, // Off by default — opt-in for stale-settings edge case
|
||||
quality_fallback_behavior: "ask".to_string(),
|
||||
skip_sink_switch: false, // Off by default — only for JACK/DAW routing setups
|
||||
allow_quality_fallback: false, // Off by default — fail rather than silently downgrade
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,6 +184,14 @@ impl AudioSettingsStore {
|
||||
"ALTER TABLE audio_settings ADD COLUMN quality_fallback_behavior TEXT DEFAULT 'ask'",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN skip_sink_switch INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN allow_quality_fallback INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
@@ -193,7 +210,7 @@ impl AudioSettingsStore {
|
||||
pub fn get_settings(&self) -> Result<AudioSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior FROM audio_settings WHERE id = 1",
|
||||
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch, allow_quality_fallback FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
// Parse backend_type from JSON string
|
||||
@@ -234,6 +251,8 @@ impl AudioSettingsStore {
|
||||
quality_fallback_behavior: row
|
||||
.get::<_, Option<String>>(18)?
|
||||
.unwrap_or_else(|| "ask".to_string()),
|
||||
skip_sink_switch: row.get::<_, Option<i64>>(19)?.unwrap_or(0) != 0,
|
||||
allow_quality_fallback: row.get::<_, Option<i64>>(20)?.unwrap_or(0) != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -447,6 +466,26 @@ impl AudioSettingsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_allow_quality_fallback(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET allow_quality_fallback = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set allow_quality_fallback: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_skip_sink_switch(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET skip_sink_switch = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set skip_sink_switch: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_pw_force_bitperfect(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
@@ -543,7 +582,9 @@ impl AudioSettingsStore {
|
||||
gapless_enabled = ?15,
|
||||
device_sample_rate_limits = ?16,
|
||||
pw_force_bitperfect = ?17,
|
||||
sync_audio_on_startup = ?18
|
||||
sync_audio_on_startup = ?18,
|
||||
skip_sink_switch = ?19,
|
||||
allow_quality_fallback = ?20
|
||||
WHERE id = 1",
|
||||
params![
|
||||
defaults.output_device,
|
||||
@@ -564,6 +605,8 @@ impl AudioSettingsStore {
|
||||
limits_json,
|
||||
defaults.pw_force_bitperfect as i64,
|
||||
defaults.sync_audio_on_startup as i64,
|
||||
defaults.skip_sink_switch as i64,
|
||||
defaults.allow_quality_fallback as i64,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to reset audio settings: {}", e))?;
|
||||
@@ -873,6 +916,29 @@ pub fn set_audio_normalization_target(
|
||||
store.set_normalization_target_lufs(target_lufs)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_audio_skip_sink_switch(
|
||||
state: tauri::State<'_, AudioSettingsState>,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: set_audio_skip_sink_switch {}", enabled);
|
||||
let guard = state
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
|
||||
// Constraint: cannot enable skip_sink_switch when dac_passthrough is on
|
||||
if enabled {
|
||||
let settings = store.get_settings()?;
|
||||
if settings.dac_passthrough {
|
||||
return Err("Cannot enable skip sink switch while DAC passthrough is active".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
store.set_skip_sink_switch(enabled)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_audio_gapless_enabled(
|
||||
state: tauri::State<'_, AudioSettingsState>,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! - token: pairing token used by the remote control PWA
|
||||
|
||||
use base64::Engine;
|
||||
use rand::RngCore;
|
||||
use rand::RngExt;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
@@ -248,7 +248,7 @@ impl RemoteControlSettingsState {
|
||||
|
||||
fn generate_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
rand::rng().fill(&mut bytes);
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use aes_gcm::{
|
||||
};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use keyring::Entry;
|
||||
use rand::RngCore;
|
||||
use rand::RngExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
@@ -89,7 +89,7 @@ fn load_or_create_installation_salt() -> Result<Vec<u8>, String> {
|
||||
}
|
||||
|
||||
let mut salt = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut salt);
|
||||
rand::rng().fill(&mut salt);
|
||||
fs::write(&path, BASE64.encode(salt))
|
||||
.map_err(|e| format!("Failed to write installation salt: {}", e))?;
|
||||
|
||||
@@ -119,7 +119,7 @@ fn load_or_create_machine_id_fallback() -> Result<Vec<u8>, String> {
|
||||
}
|
||||
|
||||
let mut machine_fallback = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut machine_fallback);
|
||||
rand::rng().fill(&mut machine_fallback);
|
||||
fs::write(&path, BASE64.encode(machine_fallback))
|
||||
.map_err(|e| format!("Failed to write machine fallback id: {}", e))?;
|
||||
|
||||
@@ -178,7 +178,7 @@ fn encrypt_credentials(credentials: &QobuzCredentials) -> Result<String, String>
|
||||
|
||||
// Generate random nonce
|
||||
let mut nonce_raw = [0u8; 12];
|
||||
rand::rngs::OsRng.fill_bytes(&mut nonce_raw);
|
||||
rand::rng().fill(&mut nonce_raw);
|
||||
let nonce_bytes: [u8; 12] = aes_gcm::aead::generic_array::GenericArray::from(nonce_raw).into();
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Idle/sleep inhibitor via XDG Desktop Portal.
|
||||
//!
|
||||
//! Prevents screen blanking and system suspend while audio is playing.
|
||||
//! Works on GNOME, KDE, Sway, XFCE — and inside Flatpak/Snap sandboxes.
|
||||
|
||||
use ashpd::desktop::inhibit::{InhibitFlags, InhibitOptions, InhibitProxy};
|
||||
use ashpd::desktop::Request;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Manages the XDG portal idle inhibit lifecycle.
|
||||
pub struct IdleInhibitor {
|
||||
handle: Arc<Mutex<Option<Request<()>>>>,
|
||||
}
|
||||
|
||||
impl IdleInhibitor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
handle: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire idle+suspend inhibit. No-op if already held.
|
||||
pub async fn inhibit(&self) {
|
||||
let mut guard = self.handle.lock().await;
|
||||
if guard.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
match try_inhibit().await {
|
||||
Ok(request) => {
|
||||
log::info!("[IdleInhibit] Acquired idle+suspend inhibit via XDG portal");
|
||||
*guard = Some(request);
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("[IdleInhibit] Could not acquire inhibit: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release the inhibit. No-op if not held.
|
||||
pub async fn uninhibit(&self) {
|
||||
let mut guard = self.handle.lock().await;
|
||||
if guard.take().is_some() {
|
||||
log::info!("[IdleInhibit] Released idle+suspend inhibit");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_inhibit() -> Result<Request<()>, ashpd::Error> {
|
||||
let proxy = InhibitProxy::new().await?;
|
||||
let request = proxy
|
||||
.inhibit(
|
||||
None,
|
||||
InhibitFlags::Idle | InhibitFlags::Suspend,
|
||||
InhibitOptions::default(),
|
||||
)
|
||||
.await?;
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
impl Default for IdleInhibitor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
+88
-23
@@ -12,6 +12,7 @@ pub mod session_lifecycle;
|
||||
pub mod tauri_adapter;
|
||||
|
||||
pub mod auto_theme;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod autoconfig_graphics;
|
||||
|
||||
pub mod api;
|
||||
@@ -27,10 +28,13 @@ 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;
|
||||
@@ -82,6 +86,8 @@ pub struct AppState {
|
||||
pub lastfm: Arc<Mutex<LastFmClient>>,
|
||||
pub songlink: SongLinkClient,
|
||||
pub visualizer: Visualizer,
|
||||
#[cfg(target_os = "linux")]
|
||||
pub idle_inhibitor: idle_inhibit::IdleInhibitor,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -138,6 +144,8 @@ impl AppState {
|
||||
lastfm: Arc::new(Mutex::new(LastFmClient::default())),
|
||||
songlink: SongLinkClient::new(),
|
||||
visualizer,
|
||||
#[cfg(target_os = "linux")]
|
||||
idle_inhibitor: idle_inhibit::IdleInhibitor::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,6 +429,42 @@ fn should_use_main_window_transparency() -> bool {
|
||||
.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)
|
||||
@@ -693,19 +737,15 @@ pub fn run() {
|
||||
|
||||
// Check if second instance was launched with a Qobuz link arg
|
||||
for arg in &args {
|
||||
if arg.starts_with("qobuzapp://")
|
||||
|| arg.contains("play.qobuz.com/")
|
||||
|| arg.contains("open.qobuz.com/")
|
||||
{
|
||||
if let Ok(resolved) = qbz_qobuz::resolve_link(arg) {
|
||||
log::info!("Single-instance forwarding link: {:?}", resolved);
|
||||
let _ = app.emit("link:resolved", &resolved);
|
||||
}
|
||||
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())
|
||||
.manage(AppState::with_device_and_settings(
|
||||
@@ -856,30 +896,36 @@ pub fn run() {
|
||||
// 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
|
||||
// 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 launch_handle = app.handle().clone();
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
for arg in &args[1..] {
|
||||
// skip binary name
|
||||
if arg.starts_with("qobuzapp://")
|
||||
|| arg.contains("play.qobuz.com/")
|
||||
|| arg.contains("open.qobuz.com/")
|
||||
{
|
||||
if let Ok(resolved) = qbz_qobuz::resolve_link(arg) {
|
||||
log::info!("Launch arg link resolved: {:?}", resolved);
|
||||
// Delay emission to give frontend time to mount
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
let _ = launch_handle.emit("link:resolved", &resolved);
|
||||
});
|
||||
}
|
||||
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();
|
||||
@@ -907,6 +953,7 @@ pub fn run() {
|
||||
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 {
|
||||
@@ -922,6 +969,7 @@ pub fn run() {
|
||||
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
|
||||
@@ -936,6 +984,7 @@ pub fn run() {
|
||||
legacy_player_state.get_normalization_gain(),
|
||||
legacy_player_state.is_gapless_ready(),
|
||||
legacy_player_state.get_gapless_next_track_id(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -951,6 +1000,7 @@ pub fn run() {
|
||||
legacy_player_state.get_normalization_gain(),
|
||||
legacy_player_state.is_gapless_ready(),
|
||||
legacy_player_state.get_gapless_next_track_id(),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -995,6 +1045,7 @@ pub fn run() {
|
||||
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);
|
||||
@@ -1012,6 +1063,17 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1156,6 +1218,7 @@ pub fn run() {
|
||||
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_get_user_info,
|
||||
commands_v2::v2_save_credentials,
|
||||
commands_v2::v2_clear_saved_credentials,
|
||||
@@ -1503,6 +1566,8 @@ pub fn run() {
|
||||
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,
|
||||
|
||||
@@ -2247,7 +2247,9 @@ pub async fn library_write_album_metadata_to_files_impl(
|
||||
let write_result = tokio::task::spawn_blocking({
|
||||
let request = request.clone();
|
||||
move || -> Result<(), String> {
|
||||
use lofty::{Accessor, AudioFile, ItemKey, Tag, TaggedFileExt};
|
||||
use lofty::prelude::*;
|
||||
use lofty::tag::{ItemKey, Tag};
|
||||
use lofty::config::WriteOptions;
|
||||
|
||||
// Ensure we only write each file once.
|
||||
let mut by_file: HashMap<String, &LibraryAlbumTrackMetadataUpdate> = HashMap::new();
|
||||
@@ -2300,16 +2302,19 @@ pub async fn library_write_album_metadata_to_files_impl(
|
||||
|
||||
// Album artist (not part of Accessor).
|
||||
if request.album_artist.trim().is_empty() {
|
||||
tag.remove_key(&ItemKey::AlbumArtist);
|
||||
tag.remove_key(ItemKey::AlbumArtist);
|
||||
} else {
|
||||
tag.insert_text(ItemKey::AlbumArtist, request.album_artist.trim().to_string());
|
||||
}
|
||||
|
||||
// Year / Genre
|
||||
if let Some(year) = request.year {
|
||||
tag.set_year(year);
|
||||
tag.set_date(lofty::tag::items::Timestamp {
|
||||
year: year as u16,
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
tag.remove_year();
|
||||
tag.remove_date();
|
||||
}
|
||||
|
||||
if let Some(ref genre) = request.genre {
|
||||
@@ -2326,17 +2331,17 @@ pub async fn library_write_album_metadata_to_files_impl(
|
||||
if let Some(ref cat) = request.catalog_number {
|
||||
let c = cat.trim();
|
||||
if c.is_empty() {
|
||||
tag.remove_key(&ItemKey::CatalogNumber);
|
||||
tag.remove_key(ItemKey::CatalogNumber);
|
||||
} else {
|
||||
tag.insert_text(ItemKey::CatalogNumber, c.to_string());
|
||||
}
|
||||
} else {
|
||||
tag.remove_key(&ItemKey::CatalogNumber);
|
||||
tag.remove_key(ItemKey::CatalogNumber);
|
||||
}
|
||||
}
|
||||
|
||||
tagged_file
|
||||
.save_to_path(path)
|
||||
.save_to_path(path, WriteOptions::default())
|
||||
.map_err(|_| "Failed to write tags to audio files. Check that the album folder is mounted read-write and you have permissions.".to_string())?;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Sanitization helpers for sensitive values in log output.
|
||||
//!
|
||||
//! Masks UUIDs and numeric IDs so logs remain useful for debugging
|
||||
//! without exposing full identifiers to security scanners or uploaded
|
||||
//! log files.
|
||||
|
||||
use std::fmt::Display;
|
||||
|
||||
/// Mask a UUID string, preserving first and last 8 characters.
|
||||
///
|
||||
/// `"123e4567-e89b-12d3-a456-426614174000"` → `"123e4567-****-****-****-****14174000"`
|
||||
///
|
||||
/// Non-UUID strings are returned with middle characters masked.
|
||||
pub fn mask_uuid(uuid: &str) -> String {
|
||||
// Standard UUID: 8-4-4-4-12 = 36 chars
|
||||
if uuid.len() == 36 && uuid.chars().filter(|c| *c == '-').count() == 4 {
|
||||
let first = &uuid[..8];
|
||||
let last = &uuid[28..];
|
||||
format!("{first}-****-****-****-****{last}")
|
||||
} else if uuid.len() > 8 {
|
||||
let quarter = uuid.len() / 4;
|
||||
let first = &uuid[..quarter];
|
||||
let last = &uuid[uuid.len() - quarter..];
|
||||
format!("{first}****{last}")
|
||||
} else {
|
||||
"****".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mask a numeric or string ID, preserving at most the first 4 characters.
|
||||
///
|
||||
/// `12345678` → `"1234****"`
|
||||
/// `42` → `"****"`
|
||||
pub fn mask_id(id: impl Display) -> String {
|
||||
let s = id.to_string();
|
||||
if s.len() > 4 {
|
||||
format!("{}****", &s[..4])
|
||||
} else {
|
||||
"****".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mask_uuid_standard() {
|
||||
assert_eq!(
|
||||
mask_uuid("123e4567-e89b-12d3-a456-426614174000"),
|
||||
"123e4567-****-****-****-****14174000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_uuid_short() {
|
||||
assert_eq!(mask_uuid("abcd"), "****");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_uuid_non_standard() {
|
||||
assert_eq!(mask_uuid("abcdef1234567890"), "abcd****7890");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_id_long() {
|
||||
assert_eq!(mask_id(12345678), "1234****");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_id_short() {
|
||||
assert_eq!(mask_id(42), "****");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_id_zero() {
|
||||
assert_eq!(mask_id(0), "****");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_id_five_digits() {
|
||||
assert_eq!(mask_id(10001), "1000****");
|
||||
}
|
||||
}
|
||||
@@ -73,21 +73,15 @@ pub fn get_backend_logs() -> Vec<String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_logs_to_paste(content: String) -> Result<String, String> {
|
||||
let form = reqwest::multipart::Form::new().part(
|
||||
"file",
|
||||
reqwest::multipart::Part::text(content)
|
||||
.file_name("qbz-logs.txt")
|
||||
.mime_str("text/plain")
|
||||
.map_err(|e| format!("Failed to create multipart part: {}", e))?,
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("QBZ/1.0")
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
let response = client
|
||||
.post("https://0x0.st")
|
||||
.multipart(form)
|
||||
.post("https://paste.rs/")
|
||||
.header("Content-Type", "text/plain")
|
||||
.body(content)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload logs: {}", e))?;
|
||||
|
||||
+100
-92
@@ -81,83 +81,88 @@ fn is_intel_gpu() -> bool {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// CLI flag: --autoconfig-graphics — detect environment and apply optimal settings
|
||||
if std::env::args().any(|a| a == "--autoconfig-graphics") {
|
||||
qbz_nix_lib::autoconfig_graphics::run();
|
||||
return;
|
||||
}
|
||||
// CLI flags for Linux graphics troubleshooting (X11/Wayland, GDK, GSK, DMA-BUF)
|
||||
// These are no-ops on macOS/Windows where the graphics stack is managed by the OS.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// CLI flag: --autoconfig-graphics — detect environment and apply optimal settings
|
||||
if std::env::args().any(|a| a == "--autoconfig-graphics") {
|
||||
qbz_nix_lib::autoconfig_graphics::run();
|
||||
return;
|
||||
}
|
||||
|
||||
// CLI flag: --reset-graphics — resets ALL graphics/composition settings to defaults
|
||||
if std::env::args().any(|a| a == "--reset-graphics") {
|
||||
eprintln!("[QBZ] Resetting all graphics settings to defaults...");
|
||||
let mut errors = Vec::new();
|
||||
// CLI flag: --reset-graphics — resets ALL graphics/composition settings to defaults
|
||||
if std::env::args().any(|a| a == "--reset-graphics") {
|
||||
eprintln!("[QBZ] Resetting all graphics settings to defaults...");
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Reset graphics settings (force_x11, gdk_scale, gdk_dpi_scale)
|
||||
match qbz_nix_lib::config::graphics_settings::GraphicsSettingsStore::new() {
|
||||
Ok(store) => {
|
||||
if let Err(e) = store.set_force_x11(false) {
|
||||
errors.push(format!("force_x11: {}", e));
|
||||
// Reset graphics settings (force_x11, gdk_scale, gdk_dpi_scale)
|
||||
match qbz_nix_lib::config::graphics_settings::GraphicsSettingsStore::new() {
|
||||
Ok(store) => {
|
||||
if let Err(e) = store.set_force_x11(false) {
|
||||
errors.push(format!("force_x11: {}", e));
|
||||
}
|
||||
if let Err(e) = store.set_gdk_scale(None) {
|
||||
errors.push(format!("gdk_scale: {}", e));
|
||||
}
|
||||
if let Err(e) = store.set_gdk_dpi_scale(None) {
|
||||
errors.push(format!("gdk_dpi_scale: {}", e));
|
||||
}
|
||||
if let Err(e) = store.set_gsk_renderer(None) {
|
||||
errors.push(format!("gsk_renderer: {}", e));
|
||||
}
|
||||
if let Err(e) = store.set_hardware_acceleration(true) {
|
||||
errors.push(format!("hardware_acceleration: {}", e));
|
||||
}
|
||||
}
|
||||
if let Err(e) = store.set_gdk_scale(None) {
|
||||
errors.push(format!("gdk_scale: {}", e));
|
||||
Err(e) => errors.push(format!("graphics settings store: {}", e)),
|
||||
}
|
||||
|
||||
// Reset developer settings (force_dmabuf)
|
||||
match qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new() {
|
||||
Ok(store) => {
|
||||
if let Err(e) = store.set_force_dmabuf(false) {
|
||||
errors.push(format!("force_dmabuf: {}", e));
|
||||
}
|
||||
}
|
||||
if let Err(e) = store.set_gdk_dpi_scale(None) {
|
||||
errors.push(format!("gdk_dpi_scale: {}", e));
|
||||
}
|
||||
if let Err(e) = store.set_gsk_renderer(None) {
|
||||
errors.push(format!("gsk_renderer: {}", e));
|
||||
}
|
||||
if let Err(e) = store.set_hardware_acceleration(true) {
|
||||
errors.push(format!("hardware_acceleration: {}", e));
|
||||
Err(e) => errors.push(format!("developer settings store: {}", e)),
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
eprintln!("[QBZ] All graphics settings have been reset:");
|
||||
eprintln!("[QBZ] - hardware_acceleration: true");
|
||||
eprintln!("[QBZ] - force_x11: false");
|
||||
eprintln!("[QBZ] - gdk_scale: auto");
|
||||
eprintln!("[QBZ] - gdk_dpi_scale: auto");
|
||||
eprintln!("[QBZ] - gsk_renderer: auto");
|
||||
eprintln!("[QBZ] - force_dmabuf: false");
|
||||
eprintln!("[QBZ] You can now start QBZ normally.");
|
||||
eprintln!(
|
||||
"[QBZ] Tip: Run 'qbz --autoconfig-graphics' to auto-detect optimal settings."
|
||||
);
|
||||
} else {
|
||||
eprintln!("[QBZ] Some settings could not be reset:");
|
||||
for e in &errors {
|
||||
eprintln!("[QBZ] - {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => errors.push(format!("graphics settings store: {}", e)),
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset developer settings (force_dmabuf)
|
||||
match qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new() {
|
||||
Ok(store) => {
|
||||
if let Err(e) = store.set_force_dmabuf(false) {
|
||||
errors.push(format!("force_dmabuf: {}", e));
|
||||
}
|
||||
// CLI flag: --reset-dmabuf — resets the developer force_dmabuf setting and exits
|
||||
if std::env::args().any(|a| a == "--reset-dmabuf") {
|
||||
match qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new() {
|
||||
Ok(store) => match store.set_force_dmabuf(false) {
|
||||
Ok(()) => {
|
||||
eprintln!("[QBZ] Developer force_dmabuf has been reset to false.");
|
||||
eprintln!("[QBZ] You can now start QBZ normally.");
|
||||
}
|
||||
Err(e) => eprintln!("[QBZ] Failed to reset force_dmabuf: {}", e),
|
||||
},
|
||||
Err(e) => eprintln!("[QBZ] Failed to open developer settings: {}", e),
|
||||
}
|
||||
Err(e) => errors.push(format!("developer settings store: {}", e)),
|
||||
return;
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
eprintln!("[QBZ] All graphics settings have been reset:");
|
||||
eprintln!("[QBZ] - hardware_acceleration: true");
|
||||
eprintln!("[QBZ] - force_x11: false");
|
||||
eprintln!("[QBZ] - gdk_scale: auto");
|
||||
eprintln!("[QBZ] - gdk_dpi_scale: auto");
|
||||
eprintln!("[QBZ] - gsk_renderer: auto");
|
||||
eprintln!("[QBZ] - force_dmabuf: false");
|
||||
eprintln!("[QBZ] You can now start QBZ normally.");
|
||||
eprintln!(
|
||||
"[QBZ] Tip: Run 'qbz --autoconfig-graphics' to auto-detect optimal settings."
|
||||
);
|
||||
} else {
|
||||
eprintln!("[QBZ] Some settings could not be reset:");
|
||||
for e in &errors {
|
||||
eprintln!("[QBZ] - {}", e);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// CLI flag: --reset-dmabuf — resets the developer force_dmabuf setting and exits
|
||||
if std::env::args().any(|a| a == "--reset-dmabuf") {
|
||||
match qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new() {
|
||||
Ok(store) => match store.set_force_dmabuf(false) {
|
||||
Ok(()) => {
|
||||
eprintln!("[QBZ] Developer force_dmabuf has been reset to false.");
|
||||
eprintln!("[QBZ] You can now start QBZ normally.");
|
||||
}
|
||||
Err(e) => eprintln!("[QBZ] Failed to reset force_dmabuf: {}", e),
|
||||
},
|
||||
Err(e) => eprintln!("[QBZ] Failed to open developer settings: {}", e),
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the application name/class for Linux window managers
|
||||
@@ -537,7 +542,7 @@ fn main() {
|
||||
qbz_nix_lib::logging::log_startup(&format!("[QBZ] GPU rendering: {}", gpu_status));
|
||||
}
|
||||
|
||||
// Catch GTK initialization panics and show a recovery message
|
||||
// Catch panics during startup and show a recovery message
|
||||
let result = std::panic::catch_unwind(|| qbz_nix_lib::run());
|
||||
|
||||
if let Err(panic_info) = result {
|
||||
@@ -549,33 +554,36 @@ fn main() {
|
||||
"Unknown panic".to_string()
|
||||
};
|
||||
|
||||
let is_gtk_failure = msg.contains("Failed to initialize gtk")
|
||||
|| msg.contains("Failed to initialize GTK")
|
||||
|| msg.contains("GDK_BACKEND");
|
||||
// GTK initialization failures only happen on Linux (X11/Wayland graphics stack)
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let is_gtk_failure = msg.contains("Failed to initialize gtk")
|
||||
|| msg.contains("Failed to initialize GTK")
|
||||
|| msg.contains("GDK_BACKEND");
|
||||
|
||||
if is_gtk_failure {
|
||||
eprintln!();
|
||||
eprintln!("╔══════════════════════════════════════════════════════════════╗");
|
||||
eprintln!("║ QBZ failed to start: GTK initialization error ║");
|
||||
eprintln!("╠══════════════════════════════════════════════════════════════╣");
|
||||
eprintln!("║ ║");
|
||||
eprintln!("║ This is usually caused by incompatible graphics settings. ║");
|
||||
eprintln!("║ To fix it, run: ║");
|
||||
eprintln!("║ ║");
|
||||
eprintln!("║ qbz --reset-graphics ║");
|
||||
eprintln!("║ ║");
|
||||
eprintln!("║ Or for Flatpak: ║");
|
||||
eprintln!("║ ║");
|
||||
eprintln!("║ flatpak run com.blitzfc.qbz --reset-graphics ║");
|
||||
eprintln!("║ ║");
|
||||
eprintln!("╚══════════════════════════════════════════════════════════════╝");
|
||||
eprintln!();
|
||||
eprintln!("[QBZ] Error detail: {}", msg);
|
||||
} else {
|
||||
eprintln!("[QBZ] Fatal error: {}", msg);
|
||||
eprintln!("[QBZ] If the app fails to start, try: qbz --reset-graphics");
|
||||
if is_gtk_failure {
|
||||
eprintln!();
|
||||
eprintln!("╔══════════════════════════════════════════════════════════════╗");
|
||||
eprintln!("║ QBZ failed to start: GTK initialization error ║");
|
||||
eprintln!("╠══════════════════════════════════════════════════════════════╣");
|
||||
eprintln!("║ ║");
|
||||
eprintln!("║ This is usually caused by incompatible graphics settings. ║");
|
||||
eprintln!("║ To fix it, run: ║");
|
||||
eprintln!("║ ║");
|
||||
eprintln!("║ qbz --reset-graphics ║");
|
||||
eprintln!("║ ║");
|
||||
eprintln!("║ Or for Flatpak: ║");
|
||||
eprintln!("║ ║");
|
||||
eprintln!("║ flatpak run com.blitzfc.qbz --reset-graphics ║");
|
||||
eprintln!("║ ║");
|
||||
eprintln!("╚══════════════════════════════════════════════════════════════╝");
|
||||
eprintln!();
|
||||
eprintln!("[QBZ] Error detail: {}", msg);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("[QBZ] Fatal error: {}", msg);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -57,68 +57,25 @@ fn resolve_location(
|
||||
.map(|t| t.eq_ignore_ascii_case("subdivision"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let country_name = area
|
||||
.and_then(|a| {
|
||||
if a.area_type
|
||||
.as_deref()
|
||||
.map(|t| t.eq_ignore_ascii_case("country"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
Some(a.name.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| country.map(|c| country_code_to_name(c)));
|
||||
|
||||
if is_city {
|
||||
let display = if let Some(ref cn) = country_name {
|
||||
format!("{}, {}", cn, ba.name)
|
||||
} else {
|
||||
ba.name.clone()
|
||||
};
|
||||
|
||||
return Some(ArtistLocation {
|
||||
city: Some(ba.name.clone()),
|
||||
area_id: Some(ba.id.clone()),
|
||||
country: country_name,
|
||||
country_code: cc,
|
||||
display_name: display,
|
||||
precision: LocationPrecision::City,
|
||||
});
|
||||
}
|
||||
|
||||
if is_subdivision {
|
||||
let display = if let Some(ref cn) = country_name {
|
||||
format!("{}, {}", cn, ba.name)
|
||||
} else {
|
||||
ba.name.clone()
|
||||
};
|
||||
|
||||
return Some(ArtistLocation {
|
||||
city: Some(ba.name.clone()),
|
||||
area_id: Some(ba.id.clone()),
|
||||
country: country_name,
|
||||
country_code: cc,
|
||||
display_name: display,
|
||||
precision: LocationPrecision::State,
|
||||
});
|
||||
}
|
||||
|
||||
// begin_area exists but type unknown — still use it as best guess
|
||||
let display = if let Some(ref cn) = country_name {
|
||||
format!("{}, {}", cn, ba.name)
|
||||
// MB's "country" field is where the artist is active (not where born).
|
||||
// When we have a city-level begin_area, display only the city name
|
||||
// to avoid incorrect country attribution (e.g., Zimmer: born Frankfurt,
|
||||
// but country=US because he works in the US).
|
||||
let precision = if is_city {
|
||||
LocationPrecision::City
|
||||
} else if is_subdivision {
|
||||
LocationPrecision::State
|
||||
} else {
|
||||
ba.name.clone()
|
||||
LocationPrecision::City // best guess
|
||||
};
|
||||
|
||||
return Some(ArtistLocation {
|
||||
city: Some(ba.name.clone()),
|
||||
area_id: Some(ba.id.clone()),
|
||||
country: country_name,
|
||||
country: country.map(|c| country_code_to_name(c)),
|
||||
country_code: cc,
|
||||
display_name: display,
|
||||
precision: LocationPrecision::City,
|
||||
display_name: ba.name.clone(),
|
||||
precision,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,7 +101,7 @@ fn resolve_location(
|
||||
// Non-country area (could be city without begin_area)
|
||||
let country_name = country.map(|c| country_code_to_name(c));
|
||||
let display = if let Some(ref cn) = country_name {
|
||||
format!("{}, {}", cn, a.name)
|
||||
format!("{}, {}", a.name, cn)
|
||||
} else {
|
||||
a.name.clone()
|
||||
};
|
||||
|
||||
@@ -64,7 +64,7 @@ async fn post_process_cached_track(
|
||||
};
|
||||
|
||||
// 6. Extract audio properties from FLAC file
|
||||
use lofty::AudioFile;
|
||||
use lofty::prelude::*;
|
||||
let (bit_depth, sample_rate) = match lofty::read_from_path(&new_path) {
|
||||
Ok(tagged_file) => {
|
||||
let properties = tagged_file.properties();
|
||||
@@ -137,6 +137,7 @@ pub async fn cache_track_for_offline(
|
||||
state: State<'_, AppState>,
|
||||
cache_state: State<'_, OfflineCacheState>,
|
||||
library_state: State<'_, crate::library::commands::LibraryState>,
|
||||
audio_settings: State<'_, crate::config::audio_settings::AudioSettingsState>,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
log::info!(
|
||||
@@ -171,6 +172,15 @@ pub async fn cache_track_for_offline(
|
||||
db.insert_track(&track_info, &file_path_str)?;
|
||||
}
|
||||
|
||||
// Read quality fallback preference before spawning
|
||||
let allow_fallback = audio_settings
|
||||
.store
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().and_then(|s| s.get_settings().ok()))
|
||||
.map(|s| s.allow_quality_fallback)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Clone what we need for the spawn
|
||||
let client = state.client.clone();
|
||||
let fetcher = cache_state.fetcher.clone();
|
||||
@@ -222,12 +232,18 @@ pub async fn cache_track_for_offline(
|
||||
}),
|
||||
);
|
||||
|
||||
// Get stream URL with highest quality available
|
||||
// Get stream URL — respect quality fallback preference
|
||||
let stream_url = {
|
||||
let client_guard = client.read().await;
|
||||
client_guard
|
||||
.get_stream_url_with_fallback(track_id, Quality::UltraHiRes)
|
||||
.await
|
||||
if allow_fallback {
|
||||
client_guard
|
||||
.get_stream_url_with_fallback(track_id, Quality::UltraHiRes)
|
||||
.await
|
||||
} else {
|
||||
client_guard
|
||||
.get_stream_url(track_id, Quality::UltraHiRes)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
let url = match stream_url {
|
||||
@@ -324,9 +340,15 @@ pub async fn cache_track_for_offline(
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
let retry_url = {
|
||||
let client_guard = client.read().await;
|
||||
client_guard
|
||||
.get_stream_url_with_fallback(track_id, Quality::UltraHiRes)
|
||||
.await
|
||||
if allow_fallback {
|
||||
client_guard
|
||||
.get_stream_url_with_fallback(track_id, Quality::UltraHiRes)
|
||||
.await
|
||||
} else {
|
||||
client_guard
|
||||
.get_stream_url(track_id, Quality::UltraHiRes)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
let retry_result = match retry_url {
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//!
|
||||
//! Handles complete metadata retrieval from Qobuz API and writing to FLAC tags.
|
||||
|
||||
use lofty::{Accessor, AudioFile, ItemKey, Picture, PictureType, Tag, TagExt, TaggedFileExt};
|
||||
use lofty::config::WriteOptions;
|
||||
use lofty::picture::{MimeType, Picture, PictureType};
|
||||
use lofty::prelude::*;
|
||||
use lofty::tag::{ItemKey, Tag};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -139,7 +142,10 @@ pub fn write_flac_tags(file_path: &str, metadata: &CompleteTrackMetadata) -> Res
|
||||
}
|
||||
|
||||
if let Some(year) = metadata.year {
|
||||
tag.set_year(year);
|
||||
tag.set_date(lofty::tag::items::Timestamp {
|
||||
year: year as u16,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(genre) = &metadata.genre {
|
||||
@@ -147,7 +153,7 @@ pub fn write_flac_tags(file_path: &str, metadata: &CompleteTrackMetadata) -> Res
|
||||
}
|
||||
|
||||
if let Some(isrc) = &metadata.isrc {
|
||||
tag.insert_text(ItemKey::Unknown("ISRC".to_string()), isrc.clone());
|
||||
tag.insert_text(ItemKey::Isrc, isrc.clone());
|
||||
}
|
||||
|
||||
if let Some(label) = &metadata.label {
|
||||
@@ -164,7 +170,7 @@ pub fn write_flac_tags(file_path: &str, metadata: &CompleteTrackMetadata) -> Res
|
||||
|
||||
// Save tags
|
||||
tagged_file
|
||||
.save_to_path(path)
|
||||
.save_to_path(path, WriteOptions::default())
|
||||
.map_err(|e| format!("Failed to save tags: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
@@ -186,20 +192,18 @@ pub async fn embed_artwork(file_path: &str, artwork_url: &str) -> Result<(), Str
|
||||
|
||||
// Determine MIME type from URL
|
||||
let mime_type = if artwork_url.contains(".jpg") || artwork_url.contains(".jpeg") {
|
||||
lofty::MimeType::Jpeg
|
||||
MimeType::Jpeg
|
||||
} else if artwork_url.contains(".png") {
|
||||
lofty::MimeType::Png
|
||||
MimeType::Png
|
||||
} else {
|
||||
lofty::MimeType::Jpeg // Default to JPEG
|
||||
MimeType::Jpeg // Default to JPEG
|
||||
};
|
||||
|
||||
// Create picture
|
||||
let picture = Picture::new_unchecked(
|
||||
PictureType::CoverFront,
|
||||
Some(mime_type),
|
||||
None,
|
||||
artwork_bytes.to_vec(),
|
||||
);
|
||||
let picture = Picture::unchecked(artwork_bytes.to_vec())
|
||||
.pic_type(PictureType::CoverFront)
|
||||
.mime_type(mime_type)
|
||||
.build();
|
||||
|
||||
// Read file
|
||||
let path = Path::new(file_path);
|
||||
@@ -218,7 +222,7 @@ pub async fn embed_artwork(file_path: &str, artwork_url: &str) -> Result<(), Str
|
||||
|
||||
// Save
|
||||
tagged_file
|
||||
.save_to_path(path)
|
||||
.save_to_path(path, WriteOptions::default())
|
||||
.map_err(|e| format!("Failed to save artwork: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -119,7 +119,7 @@ async fn migrate_single_track(
|
||||
};
|
||||
|
||||
// 6. Extract audio properties from FLAC file
|
||||
use lofty::AudioFile;
|
||||
use lofty::prelude::*;
|
||||
let (bit_depth, sample_rate) = match lofty::read_from_path(&new_path) {
|
||||
Ok(tagged_file) => {
|
||||
let properties = tagged_file.properties();
|
||||
|
||||
@@ -513,6 +513,7 @@ fn try_init_stream_with_backend(
|
||||
exclusive_mode: audio_settings.exclusive_mode,
|
||||
alsa_plugin: audio_settings.alsa_plugin,
|
||||
pw_force_bitperfect: audio_settings.pw_force_bitperfect,
|
||||
skip_sink_switch: audio_settings.skip_sink_switch,
|
||||
};
|
||||
|
||||
// For ALSA backend with hw: devices, try direct ALSA first (Linux only)
|
||||
@@ -580,6 +581,9 @@ pub struct PlaybackEvent {
|
||||
/// Track ID of the gapless-queued next track (0 = none queued)
|
||||
#[serde(default)]
|
||||
pub gapless_next_track_id: u64,
|
||||
/// Streaming buffer progress (0.0-1.0), None when fully cached
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub buffer_progress: Option<f32>,
|
||||
}
|
||||
|
||||
/// Shared state between main thread and audio thread
|
||||
@@ -2776,6 +2780,7 @@ impl Player {
|
||||
normalization_gain: self.state.get_normalization_gain(),
|
||||
gapless_ready: self.state.is_gapless_ready(),
|
||||
gapless_next_track_id: self.state.get_gapless_next_track_id(),
|
||||
buffer_progress: None, // Legacy player doesn't track streaming buffer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3926,7 +3926,7 @@ async fn deferred_renderer_join(
|
||||
log::error!("[QConnect] Deferred renderer max quality report failed: {err}");
|
||||
}
|
||||
|
||||
log::info!("[QConnect] Deferred renderer join complete for session {session_uuid}");
|
||||
log::info!("[QConnect] Deferred renderer join complete");
|
||||
|
||||
// Re-request session state so the server sends an updated renderer list
|
||||
// (including ourselves). Without this, the frontend may not see QBZ as a
|
||||
@@ -4151,7 +4151,7 @@ pub async fn v2_qconnect_send_command_with_admission(
|
||||
.await
|
||||
{
|
||||
Ok(uuid) => {
|
||||
log::info!("[QConnect] send_command_with_admission: sent uuid={uuid}");
|
||||
log::info!("[QConnect] send_command_with_admission: sent uuid={}", crate::log_sanitize::mask_uuid(&uuid));
|
||||
Ok(uuid)
|
||||
}
|
||||
Err(err) => {
|
||||
|
||||
@@ -724,7 +724,7 @@ impl QueueManager {
|
||||
return;
|
||||
}
|
||||
|
||||
use rand::{Rng, SeedableRng};
|
||||
use rand::{RngExt, SeedableRng};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// Create seeded RNG from current timestamp
|
||||
@@ -736,7 +736,7 @@ impl QueueManager {
|
||||
|
||||
// Proper Fisher-Yates shuffle - each iteration gets a NEW random number
|
||||
for i in (1..order.len()).rev() {
|
||||
let j = rng.gen_range(0..=i);
|
||||
let j = rng.random_range(0..=i);
|
||||
order.swap(i, j);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,7 +901,7 @@ pub async fn reco_get_home_resolved(
|
||||
// Shuffle artists so every favorite gets a fair chance
|
||||
let shuffled_artists = {
|
||||
let mut ids = favorite_artist_ids.clone();
|
||||
ids.shuffle(&mut rand::thread_rng());
|
||||
ids.shuffle(&mut rand::rng());
|
||||
ids
|
||||
};
|
||||
|
||||
@@ -958,7 +958,7 @@ pub async fn reco_get_home_resolved(
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
all_albums.retain(|a| seen.insert(a.id.clone()));
|
||||
}
|
||||
all_albums.shuffle(&mut rand::thread_rng());
|
||||
all_albums.shuffle(&mut rand::rng());
|
||||
all_albums.truncate(limit_favorites as usize);
|
||||
all_albums
|
||||
};
|
||||
|
||||
@@ -345,8 +345,7 @@ pub async fn activate_offline_session(app: &tauri::AppHandle) -> Result<(), Stri
|
||||
// settings, and cached data. Fall back to 0 if never logged in.
|
||||
let offline_user_id = UserDataPaths::load_last_user_id().unwrap_or(0);
|
||||
log::info!(
|
||||
"[SessionLifecycle] Activating offline session (user_id={}{})",
|
||||
offline_user_id,
|
||||
"[SessionLifecycle] Activating offline session{}",
|
||||
if offline_user_id == 0 { " — no previous session" } else { "" }
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "QBZ",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"identifier": "com.blitzfc.qbz",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -31,6 +31,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["qobuzapp"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
@@ -45,7 +52,8 @@
|
||||
"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"
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": "-"
|
||||
},
|
||||
"linux": {
|
||||
"deb": {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { X, Github, Globe, ExternalLink } from 'lucide-svelte';
|
||||
import { X, Globe, ExternalLink } from 'lucide-svelte';
|
||||
import { SiGithub } from '@icons-pack/svelte-simple-icons';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { getName, getVersion } from '@tauri-apps/api/app';
|
||||
import { onMount } from 'svelte';
|
||||
import QobuzLegalNotice from '$lib/components/QobuzLegalNotice.svelte';
|
||||
import { platform } from '$lib/utils/platform';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -35,9 +38,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Detect platform for Easter eggs
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac/.test(navigator.platform);
|
||||
const platformLabel = isMac ? 'macOS (Tauri 2.0)' : 'Linux (Tauri 2.0)';
|
||||
const platformLabel = platform === 'macos' ? 'macOS (Tauri 2.0)' : platform === 'windows' ? 'Windows (Tauri 2.0)' : 'Linux (Tauri 2.0)';
|
||||
|
||||
function handleOpenUrl(url: string) {
|
||||
openUrl(url).catch(err => console.error('Failed to open URL:', err));
|
||||
@@ -67,8 +68,7 @@
|
||||
<div class="modal-content">
|
||||
<!-- Description -->
|
||||
<p class="description">
|
||||
A native music streaming client for Linux, designed for audiophiles who need
|
||||
bit-perfect Hi-Fi playback without browser sample rate limitations.
|
||||
{$t('about.description')}
|
||||
</p>
|
||||
|
||||
<QobuzLegalNotice showCheckbox={false} />
|
||||
@@ -76,76 +76,76 @@
|
||||
<!-- Links -->
|
||||
<div class="links">
|
||||
<button class="link-btn" onclick={() => handleOpenUrl('https://github.com/vicrodh/qbz')}>
|
||||
<Github size={16} />
|
||||
<SiGithub size={16} />
|
||||
<span>GitHub</span>
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
<button class="link-btn" onclick={() => handleOpenUrl(releaseUrl)}>
|
||||
<ExternalLink size={16} />
|
||||
<span>Release</span>
|
||||
<span>{$t('about.release')}</span>
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
<button class="link-btn" onclick={() => handleOpenUrl('https://qbz.lol')}>
|
||||
<Globe size={16} />
|
||||
<span>Website</span>
|
||||
<span>{$t('about.website')}</span>
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Build Info -->
|
||||
<div class="info-section">
|
||||
<h3>Build Info</h3>
|
||||
<h3>{$t('about.buildInfo.heading')}</h3>
|
||||
<div class="info-grid">
|
||||
<span class="label">Version</span>
|
||||
<span class="label">{$t('about.buildInfo.version')}</span>
|
||||
<span class="value">{appVersion}</span>
|
||||
<span class="label">Codename</span>
|
||||
<span class="value codename">Limpiando la casa</span>
|
||||
<span class="label">License</span>
|
||||
<span class="label">{$t('about.buildInfo.codename')}</span>
|
||||
<span class="value codename">Bye bye plain auth</span>
|
||||
<span class="label">{$t('about.buildInfo.license')}</span>
|
||||
<span class="value">MIT</span>
|
||||
<span class="label">Platform</span>
|
||||
<span class="label">{$t('about.buildInfo.license')}</span>
|
||||
<span class="value">{platformLabel}</span>
|
||||
<span class="label">Build</span>
|
||||
<span class="value">{BUILD_DATE}{#if BUILD_COMMIT} <span class="commit">({BUILD_COMMIT})</span>{/if}</span>
|
||||
<span class="label">{$t('about.buildInfo.build')}</span>
|
||||
<span class="value">{BUILD_DATE} {#if BUILD_COMMIT}<span class="commit">({BUILD_COMMIT})</span>{/if}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attributions -->
|
||||
<div class="info-section">
|
||||
<h3>Acknowledgments</h3>
|
||||
<h3>{$t('about.attributions.heading')}</h3>
|
||||
<div class="attributions">
|
||||
<div class="attribution">
|
||||
<strong>Qobuz™</strong> — Music streaming service and API
|
||||
<strong>Qobuz™</strong> — {$t('about.attributions.qobuzDesc')}
|
||||
</div>
|
||||
<div class="attribution">
|
||||
<strong>Tauri</strong> — Application framework (MIT/Apache-2.0)
|
||||
<strong>Tauri</strong> — {$t('about.attributions.tauriDesc')} (MIT/Apache-2.0)
|
||||
</div>
|
||||
<div class="attribution">
|
||||
<strong>Svelte</strong> — UI framework (MIT)
|
||||
<strong>Svelte</strong> — {$t('about.attributions.svelteDesc')} (MIT)
|
||||
</div>
|
||||
<div class="attribution">
|
||||
<strong>Rodio + Symphonia</strong> — Audio playback engine
|
||||
<strong>Rodio + Symphonia</strong> — {$t('about.attributions.rodioSymphoniaDesc')}
|
||||
</div>
|
||||
<div class="attribution">
|
||||
<strong>Lucide</strong> — Icon library (ISC)
|
||||
<strong>Lucide</strong> — {$t('about.attributions.lucideDesc')} (ISC)
|
||||
</div>
|
||||
<div class="attribution">
|
||||
<strong>MusicBrainz</strong> — Music metadata database
|
||||
<strong>MusicBrainz</strong> — {$t('about.attributions.musicBrainzDesc')}
|
||||
</div>
|
||||
<div class="attribution">
|
||||
<strong>Song.link/Odesli</strong> — Music link aggregation API
|
||||
<strong>Song.link/Odesli</strong> — {$t('about.attributions.songLinkOdesliDesc')}
|
||||
</div>
|
||||
<div class="attribution">
|
||||
<strong>LRCLIB</strong> — Synced lyrics provider
|
||||
<strong>LRCLIB</strong> — {$t('about.attributions.lrclibDesc')}
|
||||
</div>
|
||||
<div class="attribution">
|
||||
<strong>lyrics.ovh</strong> — Lyrics API
|
||||
<strong>lyrics.ovh</strong> — {$t('about.attributions.lyricsOVHDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Author -->
|
||||
<div class="info-section author-section">
|
||||
<h3>Author</h3>
|
||||
<h3>{$t('about.author')}</h3>
|
||||
<button class="author-pill" onclick={() => handleOpenUrl('https://github.com/vicrodh')}>
|
||||
<img src="https://github.com/vicrodh.png?size=32" alt="vicrodh" class="author-avatar" />
|
||||
vicrodh
|
||||
@@ -155,7 +155,7 @@
|
||||
|
||||
<!-- Contributors -->
|
||||
<div class="info-section">
|
||||
<h3>Contributors</h3>
|
||||
<h3>{$t('about.contributors')}</h3>
|
||||
<div class="contributors">
|
||||
<button class="contributor-link" onclick={() => handleOpenUrl('https://github.com/vorce')}>
|
||||
<img src="https://github.com/vorce.png?size=28" alt="vorce" class="contributor-avatar" />
|
||||
@@ -192,9 +192,7 @@
|
||||
<img src="/mexico-flag.svg" alt="México" class="inline-icon flag" />
|
||||
</p>
|
||||
<p class="signature-detail">
|
||||
Hatred towards all those companies that discriminate against the Linux community
|
||||
and don't provide us with a decent client for their product.
|
||||
<img src="/Tux.svg" alt="Tux" class="inline-icon tux" class:mac-tux={isMac} />
|
||||
{$t('about.signatureDetail')} <img src="/Tux.svg" alt="Tux" class="inline-icon tux" class:mac-tux={platform === 'macos'} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { Play, Disc3, Heart, HardDrive } from 'lucide-svelte';
|
||||
import { t, locale } from 'svelte-i18n';
|
||||
import AlbumMenu from './AlbumMenu.svelte';
|
||||
import {
|
||||
subscribe as subscribeAlbumFavorites,
|
||||
@@ -111,7 +112,7 @@
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
return date.toLocaleDateString('en-US', {
|
||||
return date.toLocaleDateString($locale ? $locale : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
@@ -246,7 +247,7 @@
|
||||
aria-disabled={!favoriteAvailable || isToggling}
|
||||
disabled={!favoriteAvailable || isToggling}
|
||||
onclick={handleToggleFavorite}
|
||||
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
title={isFavorite ? $t('actions.removeFromFavorites') : $t('actions.addToFavorites')}
|
||||
>
|
||||
<Heart size={18} fill={isFavorite ? 'white' : 'none'} color="white" />
|
||||
</button>
|
||||
@@ -255,7 +256,7 @@
|
||||
<div class="overlay-btn--spacer"></div>
|
||||
{/if}
|
||||
{#if onPlay}
|
||||
<button class="overlay-btn" type="button" onclick={handlePlay} title="Play">
|
||||
<button class="overlay-btn" type="button" onclick={handlePlay} title={$t('actions.play')}>
|
||||
<Play size={18} fill="white" color="white" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Play, ChevronDown, ChevronUp, Loader2, X } from 'lucide-svelte';
|
||||
import { t, locale } from 'svelte-i18n';
|
||||
import { Play, ChevronDown, ChevronUp, LoaderCircle, X } from 'lucide-svelte';
|
||||
import type { AlbumCredits, QobuzAlbum, Performer, TrackCredits } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -174,8 +175,8 @@
|
||||
<div class="credits-modal" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||
{#if loading}
|
||||
<div class="loading-state">
|
||||
<Loader2 size={32} class="spinner" />
|
||||
<span>Loading credits...</span>
|
||||
<LoaderCircle size={32} class="spinner" />
|
||||
<span>{ $t('toast.loadingCredits') }</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error-state">
|
||||
@@ -189,7 +190,7 @@
|
||||
<h2 class="album-title">{credits.album.title}</h2>
|
||||
<span class="album-artist">{credits.album.artist}</span>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose} aria-label="Close">
|
||||
<button class="close-btn" onclick={onClose} aria-label={ $t('actions.close') }>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -208,7 +209,7 @@
|
||||
<div class="album-meta">
|
||||
{#if credits.album.label}
|
||||
<p class="meta-row">
|
||||
<span class="meta-label">Released by</span>
|
||||
<span class="meta-label">{$t('album.releasedBy')}</span>
|
||||
{#if credits.album.label_id && onLabelClick}
|
||||
<button
|
||||
class="label-link"
|
||||
@@ -223,7 +224,7 @@
|
||||
<span class="meta-value label-name">{credits.album.label}</span>
|
||||
{/if}
|
||||
{#if credits.album.release_date}
|
||||
<span class="meta-date">on {new Date(credits.album.release_date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}</span>
|
||||
<span class="meta-date">{$t('album.releasedOn')} {new Date(credits.album.release_date).toLocaleDateString($locale ? $locale : 'en-us', { month: 'long', day: 'numeric', year: 'numeric' })}</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
@@ -231,9 +232,9 @@
|
||||
{#if credits.album.genre}
|
||||
<p class="meta-row">
|
||||
<span class="meta-value">{credits.album.genre}</span>
|
||||
<span class="meta-separator">-</span>
|
||||
<span class="meta-value">{credits.album.track_count} Tracks</span>
|
||||
<span class="meta-separator">-</span>
|
||||
<span class="meta-separator">·</span>
|
||||
<span class="meta-value">{credits.album.track_count} {$t('album.tracks')}</span>
|
||||
<span class="meta-separator">·</span>
|
||||
<span class="meta-value">{credits.album.duration}</span>
|
||||
</p>
|
||||
{/if}
|
||||
@@ -263,7 +264,7 @@
|
||||
class:active={activeTab === 'credits'}
|
||||
onclick={() => activeTab = 'credits'}
|
||||
>
|
||||
Credits
|
||||
{ $t('player.credits') }
|
||||
</button>
|
||||
<button
|
||||
class="tab-btn"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Portal from './Portal.svelte';
|
||||
import {
|
||||
ChevronRight,
|
||||
MoreHorizontal,
|
||||
Ellipsis,
|
||||
ListPlus,
|
||||
ListEnd,
|
||||
ListMusic,
|
||||
@@ -325,7 +325,7 @@
|
||||
}}
|
||||
aria-label="Album actions"
|
||||
>
|
||||
<MoreHorizontal size={20} color="currentColor" />
|
||||
<Ellipsis size={20} color="currentColor" />
|
||||
</button>
|
||||
|
||||
{#if isOpen && portalTarget}
|
||||
@@ -397,7 +397,7 @@
|
||||
{#if onShareQobuz}
|
||||
<button class="menu-item" onclick={() => handleAction(onShareQobuz)}>
|
||||
<Link size={14} />
|
||||
<span>Qobuz™ link</span>
|
||||
<span>{$t('actions.qobuzLink')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onShareSonglink}
|
||||
@@ -435,14 +435,14 @@
|
||||
}}
|
||||
>
|
||||
<CloudDownload size={14} />
|
||||
<span>Make album available offline</span>
|
||||
<span>{$t('actions.makeAvailableOffline')}</span>
|
||||
<ChevronRight size={14} class="chevron" />
|
||||
{#if downloadOpen}
|
||||
<div class="submenu" bind:this={downloadSubmenuEl} style={downloadSubmenuStyle}>
|
||||
{#if onReDownloadAlbum}
|
||||
<button class="menu-item" onclick={() => handleAction(onReDownloadAlbum)}>
|
||||
<RefreshCw size={14} />
|
||||
<span>Refresh album offline copy</span>
|
||||
<span>{$t('actions.refreshOfflineCopy')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -451,7 +451,7 @@
|
||||
{:else}
|
||||
<button class="menu-item" onclick={() => handleAction(onDownload)}>
|
||||
<CloudDownload size={14} />
|
||||
<span>Make album available offline</span>
|
||||
<span>{$t('actions.makeAvailableOffline')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ListPlus, ListEnd, ListMusic, Heart, HeartOff, Trash2, X, ChevronDown } from 'lucide-svelte';
|
||||
import { ListPlus, ListEnd, ListMusic, Heart, HeartOff, Trash2, X, ChevronDown, CloudDownload } from 'lucide-svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -10,6 +10,7 @@
|
||||
onAddFavorites?: () => void;
|
||||
onRemoveFavorites?: () => void;
|
||||
onRemoveFromPlaylist?: () => void;
|
||||
onMakeOffline?: () => void;
|
||||
onClearSelection: () => void;
|
||||
placement?: 'top' | 'bottom';
|
||||
}
|
||||
@@ -22,6 +23,7 @@
|
||||
onAddFavorites,
|
||||
onRemoveFavorites,
|
||||
onRemoveFromPlaylist,
|
||||
onMakeOffline,
|
||||
onClearSelection,
|
||||
placement = 'bottom',
|
||||
}: Props = $props();
|
||||
@@ -77,11 +79,11 @@
|
||||
<div class="queue-dropdown">
|
||||
<button class="dropdown-item" onclick={handlePlayNext}>
|
||||
<ListPlus size={14} />
|
||||
<span>Play next</span>
|
||||
<span>{$t('actions.playNext')}</span>
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={handlePlayLater}>
|
||||
<ListEnd size={14} />
|
||||
<span>Add to queue</span>
|
||||
<span>{$t('actions.addToQueue')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -92,6 +94,13 @@
|
||||
<span>{$t('actions.addToPlaylist')}</span>
|
||||
</button>
|
||||
|
||||
{#if onMakeOffline}
|
||||
<button class="action-btn" onclick={onMakeOffline} title={$t('actions.makeAvailableOffline')}>
|
||||
<CloudDownload size={15} />
|
||||
<span>{$t('actions.makeAvailableOffline')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if onAddFavorites}
|
||||
<button class="action-btn" onclick={onAddFavorites} title={$t('actions.addToFavorites')}>
|
||||
<Heart size={15} />
|
||||
@@ -113,7 +122,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button class="clear-btn" onclick={onClearSelection} title="Clear selection">
|
||||
<button class="clear-btn" onclick={onClearSelection} title={ $t('actions.clearSelection') }>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { X, Cast, Loader2, Monitor, Wifi, Tv, Speaker, Power } from 'lucide-svelte';
|
||||
import { X, Cast, LoaderCircle, Monitor, Wifi, Tv, Speaker, Power } from 'lucide-svelte';
|
||||
import {
|
||||
subscribe as subscribeCast,
|
||||
getCastState,
|
||||
@@ -175,7 +176,7 @@
|
||||
<div class="overlay" onclick={onClose} onkeydown={(e) => e.key === 'Escape' && onClose()} role="presentation">
|
||||
<div class="picker" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" tabindex="-1">
|
||||
<div class="header">
|
||||
<h3>Cast to Device</h3>
|
||||
<h3>{$t('player.castToDevice')}</h3>
|
||||
<button class="close-btn" onclick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -188,13 +189,13 @@
|
||||
<div class="connected-info">
|
||||
<ProtocolIcon size={20} />
|
||||
<div class="connected-text">
|
||||
<span class="connected-label">Connected to</span>
|
||||
<span class="connected-label">{$t('player.connectedTo')}</span>
|
||||
<span class="connected-name">{castState.device.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="disconnect-btn" onclick={handleDisconnect}>
|
||||
<Power size={16} />
|
||||
<span>Disconnect</span>
|
||||
<span>{ $t('settings.integrations.disconnect') }</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -229,13 +230,13 @@
|
||||
<div class="content">
|
||||
{#if connecting}
|
||||
<div class="loading">
|
||||
<Loader2 size={32} class="spin" />
|
||||
<p>Connecting...</p>
|
||||
<LoaderCircle size={32} class="spin" />
|
||||
<p>{$t('integrations.connecting')}</p>
|
||||
</div>
|
||||
{:else if loading && devices().length === 0}
|
||||
<div class="loading">
|
||||
<Loader2 size={32} class="spin" />
|
||||
<p>Searching for devices...</p>
|
||||
<LoaderCircle size={32} class="spin" />
|
||||
<p>{$t('toast.loadingDevices')}</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
@@ -244,8 +245,8 @@
|
||||
{:else if devices().length === 0}
|
||||
<div class="empty">
|
||||
<Wifi size={32} />
|
||||
<p>No {getProtocolLabel(activeProtocol)} devices found</p>
|
||||
<p class="hint">Make sure devices are on the same network</p>
|
||||
<p>{$t('settings.integrations.noProtocolDeviceFound', { "values": { "protocol": getProtocolLabel(activeProtocol) } })}</p>
|
||||
<p class="hint">{$t('settings.integrations.noProtocolDeviceFoundHint')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="devices">
|
||||
@@ -383,6 +384,7 @@
|
||||
}
|
||||
|
||||
.hint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { X, CheckCircle, Check, AlertTriangle, XCircle, Search, Loader2 } from 'lucide-svelte';
|
||||
import { X, CircleCheckBig, Check, TriangleAlert, CircleX, Search, LoaderCircle } from 'lucide-svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { t } from '$lib/i18n';
|
||||
import WizardStepper, { type Step } from './wizard/WizardStepper.svelte';
|
||||
@@ -457,22 +457,22 @@
|
||||
|
||||
{#if dacType === 'usb'}
|
||||
<span class="dac-type usb">
|
||||
<CheckCircle size={14} />
|
||||
<CircleCheckBig size={14} />
|
||||
{$t('dacWizard.detectDac.validation.usbDac')}
|
||||
</span>
|
||||
{:else if dacType === 'pci'}
|
||||
<span class="dac-type pci">
|
||||
<AlertTriangle size={14} />
|
||||
<TriangleAlert size={14} />
|
||||
{$t('dacWizard.detectDac.validation.pciDac')}
|
||||
</span>
|
||||
{:else if dacType === 'bluetooth'}
|
||||
<span class="dac-type bluetooth">
|
||||
<XCircle size={14} />
|
||||
<CircleX size={14} />
|
||||
{$t('dacWizard.detectDac.validation.bluetoothDac')}
|
||||
</span>
|
||||
{:else if dacType === 'virtual'}
|
||||
<span class="dac-type virtual">
|
||||
<XCircle size={14} />
|
||||
<CircleX size={14} />
|
||||
{$t('dacWizard.detectDac.validation.virtualDac')}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -480,7 +480,7 @@
|
||||
{:else if dacValidation === 'invalid'}
|
||||
<div class="validation-feedback">
|
||||
<span class="validation-status invalid">
|
||||
<XCircle size={14} />
|
||||
<CircleX size={14} />
|
||||
{$t('dacWizard.detectDac.validation.invalidFormat')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -496,7 +496,7 @@
|
||||
disabled={isQueryingDac}
|
||||
>
|
||||
{#if isQueryingDac}
|
||||
<Loader2 size={16} class="spin" />
|
||||
<LoaderCircle size={16} class="spin" />
|
||||
{$t('dacWizard.detectDac.query.querying')}
|
||||
{:else}
|
||||
<Search size={16} />
|
||||
@@ -679,7 +679,7 @@
|
||||
|
||||
{:else if currentStep === 'done'}
|
||||
<div class="done-content">
|
||||
<CheckCircle size={64} class="done-icon" />
|
||||
<CircleCheckBig size={64} class="done-icon" />
|
||||
|
||||
{#if dacCapabilities?.description || dacNodeName}
|
||||
<div class="done-dac-info">
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-results">No devices found</div>
|
||||
<div class="no-results">{$t('settings.audio.noDevicesFound')}</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { CloudDownload, AlertCircle, CloudOff } from 'lucide-svelte';
|
||||
import { CloudDownload, CircleAlert, CloudOff } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import {
|
||||
subscribe as subscribeOffline,
|
||||
@@ -106,7 +106,7 @@
|
||||
/>
|
||||
</svg>
|
||||
{:else if status === 'failed'}
|
||||
<AlertCircle {size} />
|
||||
<CircleAlert {size} />
|
||||
{:else if isOffline}
|
||||
<CloudOff {size} />
|
||||
{:else}
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
{option}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="no-results">No matches found</div>
|
||||
<div class="no-results">{$t('settings.noMatchesFound')}</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { X, Shuffle, SkipBack, Play, Pause, SkipForward, Repeat, Heart, List, Maximize2, MoreHorizontal, Cast } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { X, Shuffle, SkipBack, Play, Pause, SkipForward, Repeat, Heart, List, Maximize2, Ellipsis, Cast } from 'lucide-svelte';
|
||||
import QualityBadge from './QualityBadge.svelte';
|
||||
import StackIcon from './StackIcon.svelte';
|
||||
import LyricsLines from './lyrics/LyricsLines.svelte';
|
||||
@@ -239,7 +240,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button class="close-btn" onclick={onClose} title="Close (Esc)">
|
||||
<button class="close-btn" onclick={onClose} title={$t('actions.close') + ` (${$t('keys.esc')})`}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
@@ -256,7 +257,7 @@
|
||||
<div class="artist-album-row">
|
||||
<StackIcon size={16} class="stack-icon" onClick={onContextClick} />
|
||||
<h2 class="artist">{artist}</h2>
|
||||
<span class="separator">·</span>
|
||||
<span class="separator">·</span>
|
||||
<h3 class="album">{album}</h3>
|
||||
</div>
|
||||
<div class="quality-info">
|
||||
@@ -291,28 +292,28 @@
|
||||
class="control-btn"
|
||||
class:active={isShuffle}
|
||||
onclick={onToggleShuffle}
|
||||
title="Shuffle"
|
||||
title={$t('player.shuffle')}
|
||||
>
|
||||
<Shuffle size={22} />
|
||||
</button>
|
||||
<button class="control-btn primary" onclick={onSkipBack} title="Previous">
|
||||
<button class="control-btn primary" onclick={onSkipBack} title={$t('player.previous')}>
|
||||
<SkipBack size={28} />
|
||||
</button>
|
||||
<button class="control-btn primary play-pause" onclick={onTogglePlay} title={isPlaying ? 'Pause' : 'Play'}>
|
||||
<button class="control-btn primary play-pause" onclick={onTogglePlay} title={isPlaying ? $t('player.pause') : $t('player.play')}>
|
||||
{#if isPlaying}
|
||||
<Pause size={36} />
|
||||
{:else}
|
||||
<Play size={36} class="play-icon" />
|
||||
{/if}
|
||||
</button>
|
||||
<button class="control-btn primary" onclick={onSkipForward} title="Next">
|
||||
<button class="control-btn primary" onclick={onSkipForward} title={$t('player.next')}>
|
||||
<SkipForward size={28} />
|
||||
</button>
|
||||
<button
|
||||
class="control-btn"
|
||||
class:active={repeatMode !== 'off'}
|
||||
onclick={onToggleRepeat}
|
||||
title={repeatMode === 'off' ? 'Repeat' : repeatMode === 'all' ? 'Repeat All' : 'Repeat One'}
|
||||
title={repeatMode === 'off' ? $t('player.repeat') : repeatMode === 'all' ? $t('player.repeatAll') : $t('player.repeatOne')}
|
||||
>
|
||||
<Repeat size={22} />
|
||||
{#if repeatMode === 'one'}
|
||||
@@ -333,7 +334,7 @@
|
||||
aria-valuenow={volume}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
title="Volume"
|
||||
title={$t('player.volume')}
|
||||
>
|
||||
<div class="volume-fill" style="width: {volume}%"></div>
|
||||
<div class="volume-thumb" style="left: {volume}%"></div>
|
||||
@@ -342,17 +343,17 @@
|
||||
|
||||
<!-- Bottom Actions -->
|
||||
<div class="bottom-actions">
|
||||
<button class="action-btn" onclick={onOpenQueue} title="Queue">
|
||||
<button class="action-btn" onclick={onOpenQueue} title={$t('player.queue')}>
|
||||
<List size={22} />
|
||||
</button>
|
||||
<button class="action-btn" onclick={onOpenFocusMode} title="Immersive Mode">
|
||||
<button class="action-btn" onclick={onOpenFocusMode} title={$t('player.fullScreen')}>
|
||||
<Maximize2 size={22} />
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
class:cast-active={isCastConnected}
|
||||
onclick={onCast}
|
||||
title={isCastConnected ? 'Casting - Click to manage' : 'Cast to device'}
|
||||
title={isCastConnected ? $t('player.castingManage') : $t('player.castToDevice')}
|
||||
>
|
||||
<Cast size={22} />
|
||||
</button>
|
||||
@@ -360,12 +361,12 @@
|
||||
class="action-btn"
|
||||
class:active={isFavorite}
|
||||
onclick={onToggleFavorite}
|
||||
title={isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
|
||||
title={isFavorite ? $t('actions.removeFromFavorites') : $t('actions.addToFavorites')}
|
||||
>
|
||||
<Heart size={22} fill={isFavorite ? 'var(--accent-primary)' : 'none'} color={isFavorite ? 'var(--accent-primary)' : 'currentColor'} />
|
||||
</button>
|
||||
<button class="action-btn" title="More options">
|
||||
<MoreHorizontal size={22} />
|
||||
<button class="action-btn" title={$t('actions.moreOptions')}>
|
||||
<Ellipsis size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -375,7 +376,7 @@
|
||||
{#if lyricsLoading}
|
||||
<div class="lyrics-state">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading lyrics...</span>
|
||||
<span>{$t('player.fetchingLyrics')}</span>
|
||||
</div>
|
||||
{:else if lyricsError}
|
||||
<div class="lyrics-state">
|
||||
@@ -395,7 +396,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="lyrics-state">
|
||||
<span class="no-lyrics">No lyrics available</span>
|
||||
<span class="no-lyrics">{$t('player.noLyrics')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { tick, onMount } from 'svelte';
|
||||
import { Play, Heart, MoreHorizontal, ListPlus, Share2, ListMusic, Trash2 } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { Play, Heart, Ellipsis, ListPlus, Share2, ListMusic, Trash2 } from 'lucide-svelte';
|
||||
import PlaylistCollage from './PlaylistCollage.svelte';
|
||||
import {
|
||||
openMenu as openGlobalMenu,
|
||||
@@ -182,11 +183,11 @@
|
||||
class:active={true}
|
||||
type="button"
|
||||
onclick={handleRemoveFavorite}
|
||||
title="Remove from favorites"
|
||||
title={$t('actions.removeFromFavorites')}
|
||||
>
|
||||
<Heart size={18} fill="currentColor" />
|
||||
</button>
|
||||
<button class="overlay-btn" type="button" onclick={handlePlay} title="Play">
|
||||
<button class="overlay-btn" type="button" onclick={handlePlay} title={$t('actions.play')}>
|
||||
<Play size={18} fill="white" />
|
||||
</button>
|
||||
<button
|
||||
@@ -194,9 +195,9 @@
|
||||
type="button"
|
||||
bind:this={menuTriggerRef}
|
||||
onclick={toggleMenu}
|
||||
title="More options"
|
||||
title={$t('actions.moreOptions')}
|
||||
>
|
||||
<MoreHorizontal size={18} />
|
||||
<Ellipsis size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,7 +215,7 @@
|
||||
<div class="card-subtitle">
|
||||
<span class="owner">{playlist.owner.name}</span>
|
||||
<span class="separator">-</span>
|
||||
<span class="tracks">{playlist.tracks_count} tracks</span>
|
||||
<span class="tracks">{playlist.tracks_count} {$t('playlist.tracks')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,18 +223,18 @@
|
||||
{#if menuOpen}
|
||||
<div class="playlist-menu" bind:this={menuEl} style={menuStyle} use:portal>
|
||||
<button class="menu-item" onclick={() => { onPlayNext?.(); closeMenu(); }}>
|
||||
<ListPlus size={14} /> <span>Play next</span>
|
||||
<ListPlus size={14} /> <span>{$t('actions.playNext')}</span>
|
||||
</button>
|
||||
<button class="menu-item" onclick={() => { onPlayLater?.(); closeMenu(); }}>
|
||||
<ListMusic size={14} /> <span>Add to queue</span>
|
||||
<ListMusic size={14} /> <span>{$t('actions.addToQueue')}</span>
|
||||
</button>
|
||||
<div class="menu-separator"></div>
|
||||
<button class="menu-item" onclick={() => { onShareQobuz?.(); closeMenu(); }}>
|
||||
<Share2 size={14} /> <span>Share (Qobuz)</span>
|
||||
<Share2 size={14} /> <span>{$t('actions.shareQobuz')}</span>
|
||||
</button>
|
||||
<div class="menu-separator"></div>
|
||||
<button class="menu-item menu-item--danger" onclick={() => { onRemoveFavorite?.(); closeMenu(); }}>
|
||||
<Trash2 size={14} /> <span>Remove from favorites</span>
|
||||
<Trash2 size={14} /> <span>{$t('actions.removeFromFavorites')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
const key = `favorites.tabLabels.${tab}`;
|
||||
const translated = $t(key);
|
||||
if (translated && !translated.startsWith('favorites.tabLabels.')) return translated;
|
||||
const fallback: Record<string, string> = { tracks: 'Tracks', albums: 'Albums', artists: 'Artists', playlists: 'Playlists' };
|
||||
const fallback: Record<string, string> = { tracks: $t('favorites.tabLabels.tracks'), albums: $t('favorites.tabLabels.albums'), artists: $t('favorites.tabLabels.artists'), playlists: $t('favorites.tabLabels.playlists') };
|
||||
return fallback[tab] || tab;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
class="tab-move-btn"
|
||||
onclick={() => moveUp(index)}
|
||||
disabled={index === 0}
|
||||
title={$t('actions.moveUp')}
|
||||
title={ $t('actions.moveUp') }
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
@@ -108,7 +108,7 @@
|
||||
class="tab-move-btn"
|
||||
onclick={() => moveDown(index)}
|
||||
disabled={index === tabOrder.length - 1}
|
||||
title={$t('actions.moveDown')}
|
||||
title={ $t('actions.moveDown') }
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button class="close-btn" class:visible={showControls} onclick={onClose} title={$t('actions.close') + ' (Esc)'}>
|
||||
<button class="close-btn" class:visible={showControls} onclick={onClose} title={$t('actions.close') + ` (${$t('keys.esc')})`}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
|
||||
@@ -149,18 +149,18 @@
|
||||
<div class="folder-modal-content">
|
||||
<!-- Name input -->
|
||||
<div class="modal-section">
|
||||
<h3>Name</h3>
|
||||
<h3>{$t('library.folderName')}</h3>
|
||||
<input
|
||||
type="text"
|
||||
class="folder-name-input"
|
||||
bind:value={folderName}
|
||||
placeholder="Enter folder name"
|
||||
placeholder={$t('placeholders.enterFolderName')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Icon Section -->
|
||||
<div class="modal-section">
|
||||
<h3>Icon</h3>
|
||||
<h3>{$t('favorites.iconSectionTitle')}</h3>
|
||||
|
||||
<div class="icon-grid">
|
||||
{#each presetIcons as preset}
|
||||
@@ -179,7 +179,7 @@
|
||||
<div class="custom-upload">
|
||||
<button class="upload-btn" onclick={handleUploadClick}>
|
||||
<Upload size={16} />
|
||||
<span>Upload Custom Image</span>
|
||||
<span>{$t('favorites.uploadCustomImage')}</span>
|
||||
</button>
|
||||
{#if useCustomImage && customIconPath}
|
||||
<span class="upload-filename">{customIconPath.split('/').pop()}</span>
|
||||
@@ -189,7 +189,7 @@
|
||||
|
||||
<!-- Background Section -->
|
||||
<div class="modal-section">
|
||||
<h3>Background</h3>
|
||||
<h3>{$t('library.background')}</h3>
|
||||
|
||||
<div class="color-section">
|
||||
<div class="color-grid">
|
||||
@@ -251,15 +251,15 @@
|
||||
<div class="modal-actions">
|
||||
{#if folder && onDelete}
|
||||
<button class="btn btn-danger" onclick={handleDelete} disabled={saving}>
|
||||
Delete Folder
|
||||
{$t('actions.delete')}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="modal-actions-right">
|
||||
<button class="btn btn-secondary" onclick={handleCancel} disabled={saving}>
|
||||
Cancel
|
||||
{$t('actions.cancel')}
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick={handleSave} disabled={saving || !folderName.trim()}>
|
||||
{saving ? 'Saving...' : folder ? 'Save' : 'Create'}
|
||||
{saving ? $t('actions.saving') : folder ? $t('actions.save') : $t('actions.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { X, HardDrive, Network, RefreshCw, Power, PowerOff, AlertTriangle, FolderOpen } from 'lucide-svelte';
|
||||
import { X, HardDrive, Network, RefreshCw, Power, PowerOff, TriangleAlert, FolderOpen } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
// LibraryFolder type matching Rust backend
|
||||
@@ -311,19 +311,19 @@
|
||||
{#if isNetwork && !detectedIsNetwork}
|
||||
<!-- User marked as network, but not detected -->
|
||||
<span class="form-hint override-hint warning">
|
||||
<AlertTriangle size={12} />
|
||||
<TriangleAlert size={12} />
|
||||
{$t('library.networkOverrideMarkedNetwork')}
|
||||
</span>
|
||||
{:else}
|
||||
<!-- User marked as local, but detected as network -->
|
||||
<span class="form-hint override-hint danger">
|
||||
<AlertTriangle size={12} />
|
||||
<TriangleAlert size={12} />
|
||||
{$t('library.networkOverrideMarkedLocal')}
|
||||
</span>
|
||||
{/if}
|
||||
{:else if userOverrideNetwork}
|
||||
<span class="form-hint override-hint">
|
||||
<AlertTriangle size={12} />
|
||||
<TriangleAlert size={12} />
|
||||
{$t('library.networkOverrideHint')}
|
||||
</span>
|
||||
{:else}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { SlidersHorizontal, X, Minus, Check, ChevronRight, ChevronDown, Search, Loader2 } from 'lucide-svelte';
|
||||
import { SlidersHorizontal, X, Minus, Check, ChevronRight, ChevronDown, Search, LoaderCircle } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import {
|
||||
getChildGenres,
|
||||
@@ -299,7 +299,7 @@
|
||||
{#if hasLoadedChildren || !areChildrenLoaded(parentNode.genre.id)}
|
||||
<button class="expand-btn" onclick={(e) => { e.stopPropagation(); toggleExpanded(parentNode.genre.id); }} type="button" title={isExpanded ? $t('genreFilter.collapse') : $t('genreFilter.expand')}>
|
||||
{#if isLoading}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
<LoaderCircle size={14} class="animate-spin" />
|
||||
{:else if isExpanded}
|
||||
<ChevronDown size={14} />
|
||||
{:else}
|
||||
@@ -339,7 +339,7 @@
|
||||
{#if hasGrandchildren}
|
||||
<button class="expand-btn" onclick={(e) => { e.stopPropagation(); toggleExpanded(childNode.genre.id); }} type="button" title={childExpanded ? $t('genreFilter.collapse') : $t('genreFilter.expand')}>
|
||||
{#if childLoading}
|
||||
<Loader2 size={12} class="animate-spin" />
|
||||
<LoaderCircle size={12} class="animate-spin" />
|
||||
{:else if childExpanded}
|
||||
<ChevronDown size={12} />
|
||||
{:else}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Play, MoreHorizontal } from 'lucide-svelte';
|
||||
import { Play, Ellipsis } from 'lucide-svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
artwork: string;
|
||||
@@ -29,11 +30,11 @@
|
||||
onmouseleave={() => (playBtnHovered = false)}
|
||||
>
|
||||
<Play size={16} fill="white" color="white" />
|
||||
<span>Play</span>
|
||||
<span>{ $t('actions.play') }</span>
|
||||
</button>
|
||||
<button class="more-btn">
|
||||
<MoreHorizontal size={16} color="white" />
|
||||
<span>More</span>
|
||||
<Ellipsis size={16} color="white" />
|
||||
<span>{ $t('actions.more') }</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
</div>
|
||||
|
||||
<span class="section-label">
|
||||
{section.label}
|
||||
{ $t(`home.${section.id}`) }
|
||||
{#if section.source === 'qobuz'}
|
||||
<span class="source-badge qobuz">Qobuz™</span>
|
||||
{:else if section.source === 'ml'}
|
||||
@@ -152,7 +152,7 @@
|
||||
|
||||
{#if section.id === 'recentAlbums' && section.visible}
|
||||
<div class="section-limit">
|
||||
<label class="limit-label" for="limit-recent">Items to show</label>
|
||||
<label class="limit-label" for="limit-recent">{ $t('homeSettings.itemsToShow') }</label>
|
||||
<input
|
||||
id="limit-recent"
|
||||
type="number"
|
||||
@@ -165,7 +165,7 @@
|
||||
</div>
|
||||
{:else if section.id === 'continueTracks' && section.visible}
|
||||
<div class="section-limit">
|
||||
<label class="limit-label" for="limit-continue">Items to show</label>
|
||||
<label class="limit-label" for="limit-continue">{ $t('homeSettings.itemsToShow') }</label>
|
||||
<input
|
||||
id="limit-continue"
|
||||
type="number"
|
||||
@@ -178,7 +178,7 @@
|
||||
</div>
|
||||
{:else if section.id === 'topArtists' && section.visible}
|
||||
<div class="section-limit">
|
||||
<label class="limit-label" for="limit-artists">Items to show</label>
|
||||
<label class="limit-label" for="limit-artists">{ $t('homeSettings.itemsToShow') }</label>
|
||||
<input
|
||||
id="limit-artists"
|
||||
type="number"
|
||||
@@ -191,7 +191,7 @@
|
||||
</div>
|
||||
{:else if section.id === 'favoriteAlbums' && section.visible}
|
||||
<div class="section-limit">
|
||||
<label class="limit-label" for="limit-fav-albums">Items to show</label>
|
||||
<label class="limit-label" for="limit-fav-albums">{ $t('homeSettings.itemsToShow') }</label>
|
||||
<input
|
||||
id="limit-fav-albums"
|
||||
type="number"
|
||||
@@ -204,7 +204,7 @@
|
||||
</div>
|
||||
{:else if (section.id === 'newReleases' || section.id === 'pressAwards' || section.id === 'mostStreamed' || section.id === 'qobuzissimes' || section.id === 'editorPicks') && section.visible}
|
||||
<div class="section-limit">
|
||||
<label class="limit-label" for="limit-featured">Items to show</label>
|
||||
<label class="limit-label" for="limit-featured">{ $t('homeSettings.itemsToShow') }</label>
|
||||
<input
|
||||
id="limit-featured-{section.id}"
|
||||
type="number"
|
||||
|
||||
@@ -32,11 +32,11 @@
|
||||
let inputEl = $state<HTMLInputElement | undefined>(undefined);
|
||||
|
||||
const platformConfig: Record<string, { logo: string; alt: string; color: string }> = {
|
||||
qobuz: { logo: '/qobuz-logo.svg', alt: 'Qobuz', color: '#0170ef' },
|
||||
spotify: { logo: '/spotify-logo.svg', alt: 'Spotify', color: '#1DB954' },
|
||||
apple: { logo: '/apple-music-logo.svg', alt: 'Apple Music', color: '#fa233b' },
|
||||
tidal: { logo: '/tidal-tidal.svg', alt: 'Tidal', color: '#ffffff' },
|
||||
deezer: { logo: '/deezer-logo.svg', alt: 'Deezer', color: '#00c7f2' },
|
||||
qobuz: { logo: '/qobuz-logo.svg', alt: $t('platforms.qobuz'), color: '#0170ef' },
|
||||
spotify: { logo: '/spotify-logo.svg', alt: $t('platforms.spotify'), color: '#1DB954' },
|
||||
apple: { logo: '/apple-music-logo.svg', alt: $t('platforms.appleMusic'), color: '#fa233b' },
|
||||
tidal: { logo: '/tidal-tidal.svg', alt: $t('platforms.tidal'), color: '#ffffff' },
|
||||
deezer: { logo: '/deezer-logo.svg', alt: $t('platforms.deezer'), color: '#00c7f2' },
|
||||
};
|
||||
|
||||
function detectPlatform(value: string): Platform {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import Modal from './Modal.svelte';
|
||||
import { getConsoleLogsAsText } from '$lib/stores/consoleLogStore';
|
||||
import { showToast } from '$lib/stores/toastStore';
|
||||
import { Loader2, Copy, Check } from 'lucide-svelte';
|
||||
import { LoaderCircle, Copy, Check } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -95,7 +95,7 @@
|
||||
<div class="log-container">
|
||||
{#if isLoading}
|
||||
<div class="loading">
|
||||
<Loader2 size={20} class="spin" />
|
||||
<LoaderCircle size={20} class="spin" />
|
||||
</div>
|
||||
{:else}
|
||||
<pre class="log-output">{activeTab === 'terminal' ? terminalLogs : consoleLogs}</pre>
|
||||
@@ -112,7 +112,7 @@
|
||||
disabled={activeTab === 'terminal' ? isUploadingTerminal : isUploadingConsole}
|
||||
>
|
||||
{#if (activeTab === 'terminal' ? isUploadingTerminal : isUploadingConsole)}
|
||||
<Loader2 size={14} class="spin" />
|
||||
<LoaderCircle size={14} class="spin" />
|
||||
{$t('settings.developer.uploading')}
|
||||
{:else}
|
||||
{$t('settings.developer.uploadTab', { values: { tab: activeTab === 'terminal' ? $t('settings.developer.tabTerminal') : $t('settings.developer.tabConsole') } })}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
import { AlertCircle, CheckCircle, Loader, XCircle } from 'lucide-svelte';
|
||||
import { CircleAlert, CircleCheckBig, Loader, CircleX } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -72,7 +72,7 @@
|
||||
{#if !migrating && !progress?.completed}
|
||||
<div class="info-section">
|
||||
<div class="info-icon">
|
||||
<AlertCircle size={48} color="var(--accent-primary)" />
|
||||
<CircleAlert size={48} color="var(--accent-primary)" />
|
||||
</div>
|
||||
<h3>Legacy Downloads Detected</h3>
|
||||
<p>
|
||||
@@ -116,17 +116,17 @@
|
||||
{:else if progress?.completed}
|
||||
<div class="complete-section">
|
||||
<div class="complete-icon">
|
||||
<CheckCircle size={48} color="var(--success)" />
|
||||
<CircleCheckBig size={48} color="var(--success)" />
|
||||
</div>
|
||||
<h3>Migration Complete</h3>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<CheckCircle size={20} color="var(--success)" />
|
||||
<CircleCheckBig size={20} color="var(--success)" />
|
||||
<span>{progress.successful} successful</span>
|
||||
</div>
|
||||
{#if progress.failed > 0}
|
||||
<div class="stat error">
|
||||
<XCircle size={20} color="var(--error)" />
|
||||
<CircleX size={20} color="var(--error)" />
|
||||
<span>{progress.failed} failed</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { X } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -58,7 +59,7 @@
|
||||
<div></div>
|
||||
{/if}
|
||||
{#if showCloseButton}
|
||||
<button class="close-btn" onclick={onClose} title="Close">
|
||||
<button class="close-btn" onclick={onClose} title={$t('actions.close')}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
import { X, User, Music, Info } from 'lucide-svelte';
|
||||
import type { ResolvedMusician } from '$lib/types';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
musician: ResolvedMusician;
|
||||
@@ -82,7 +83,7 @@
|
||||
<span class="role">{musician.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose} title="Close">
|
||||
<button class="close-btn" onclick={onClose} title={ $t('actions.close') }>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -92,7 +93,7 @@
|
||||
<!-- Bands & Projects -->
|
||||
{#if musician.bands.length > 0}
|
||||
<div class="section">
|
||||
<h3>Known for</h3>
|
||||
<h3>{ $t('musician.knownFor') }</h3>
|
||||
<div class="bands-list">
|
||||
{#each musician.bands as band}
|
||||
<div class="band-item">
|
||||
@@ -116,7 +117,7 @@
|
||||
{#if showArtistCta}
|
||||
<div class="section cta-section">
|
||||
<button class="cta-btn" onclick={handleNavigateToArtist}>
|
||||
View Artist Page
|
||||
{ $t('musician.viewArtistPage') }
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -13,10 +13,11 @@
|
||||
VolumeX,
|
||||
Volume1,
|
||||
Cast,
|
||||
Mic2
|
||||
MicVocal
|
||||
} from 'lucide-svelte';
|
||||
import QualityBadge from './QualityBadge.svelte';
|
||||
import GlassSurface from './glass/GlassSurface.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
artwork?: string;
|
||||
@@ -167,7 +168,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<span class="empty-text">No track playing</span>
|
||||
<span class="empty-text">{ $t('player.noTrackPlaying') }</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -242,16 +243,16 @@
|
||||
<button class="action-btn" onclick={onOpenQueue}>
|
||||
<List size={20} />
|
||||
</button>
|
||||
<button class="action-btn" onclick={onCast} title="Cast to device">
|
||||
<button class="action-btn" onclick={onCast} title={ $t('player.castToDevice') }>
|
||||
<Cast size={20} />
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
class:active={lyricsActive}
|
||||
onclick={onToggleLyrics}
|
||||
title="Lyrics"
|
||||
title={ $t('player.lyrics') }
|
||||
>
|
||||
<Mic2 size={20} />
|
||||
<MicVocal size={20} />
|
||||
</button>
|
||||
|
||||
<!-- Volume Control -->
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
VolumeX,
|
||||
Volume1,
|
||||
Cast,
|
||||
Mic2,
|
||||
MicVocal,
|
||||
Maximize2,
|
||||
PictureInPicture2,
|
||||
AlertTriangle
|
||||
TriangleAlert
|
||||
} from 'lucide-svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import QualityBadge from './QualityBadge.svelte';
|
||||
import AudioOutputBadges from './AudioOutputBadges.svelte';
|
||||
import QconnectBadge from './QconnectBadge.svelte';
|
||||
@@ -91,6 +92,7 @@
|
||||
qconnectBusy?: boolean;
|
||||
showQconnectDevButton?: boolean;
|
||||
volumeLocked?: boolean;
|
||||
bufferProgress?: number | null;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -145,6 +147,7 @@
|
||||
qconnectBusy = false,
|
||||
showQconnectDevButton = false,
|
||||
volumeLocked = false,
|
||||
bufferProgress = null,
|
||||
}: Props = $props();
|
||||
|
||||
let progressRef = $state<HTMLDivElement | null>(null);
|
||||
@@ -274,6 +277,9 @@
|
||||
aria-valuemax={duration}
|
||||
>
|
||||
<div class="seekbar-track">
|
||||
{#if bufferProgress != null && bufferProgress < 1}
|
||||
<div class="seekbar-buffer" style="width: {bufferProgress * 100}%"></div>
|
||||
{/if}
|
||||
<div class="seekbar-fill" style="width: {progress}%"></div>
|
||||
</div>
|
||||
<div class="seekbar-thumb" style="left: {progress}%"></div>
|
||||
@@ -395,7 +401,7 @@
|
||||
<div class="song-title-row">
|
||||
<button class="song-title" title={$translateStore('actions.trackInfo')} onclick={onTrackClick}>{trackTitle}</button>
|
||||
{#if explicit}
|
||||
<span class="explicit-badge" title="Explicit"></span>
|
||||
<span class="explicit-badge" title={ $t('library.explicit') }></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="song-meta">
|
||||
@@ -457,7 +463,7 @@
|
||||
title={$translateStore('degraded.title')}
|
||||
role="status"
|
||||
>
|
||||
<AlertTriangle size={16} />
|
||||
<TriangleAlert size={16} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -490,7 +496,7 @@
|
||||
title={isOffline ? $translateStore('offline.featureDisabled') : $translateStore('player.lyrics')}
|
||||
aria-label={isOffline ? $translateStore('offline.featureDisabled') : $translateStore('player.lyrics')}
|
||||
>
|
||||
<Mic2 size={16} aria-hidden="true" />
|
||||
<MicVocal size={16} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{#if onOpenMiniPlayer}
|
||||
@@ -662,6 +668,7 @@
|
||||
}
|
||||
|
||||
.seekbar-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: var(--border-subtle);
|
||||
@@ -669,7 +676,17 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seekbar-buffer {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
background: var(--text-muted, #666);
|
||||
opacity: 0.3;
|
||||
border-radius: 999px;
|
||||
transition: width 250ms linear;
|
||||
}
|
||||
|
||||
.seekbar-fill {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background: var(--accent-primary, #6366f1);
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
<div class="suggestions-header">
|
||||
<div class="header-left">
|
||||
<Sparkles size={18} class="sparkle-icon" />
|
||||
<h3>Suggested songs</h3>
|
||||
<h3>{ $t('playlist.suggestedSongs') }</h3>
|
||||
</div>
|
||||
<button
|
||||
class="refresh-btn"
|
||||
@@ -374,12 +374,12 @@
|
||||
{#if loading && pool.length === 0}
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Finding similar artists...</p>
|
||||
<p>{ $t('toast.findingSimilarArtists') }</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error-state">
|
||||
<p>{error}</p>
|
||||
<button onclick={() => loadSuggestions(false)}>Retry</button>
|
||||
<button onclick={() => loadSuggestions(false)}>{ $t('actions.retry') }</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="suggestions-list">
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
{/if}
|
||||
</span>
|
||||
<span class="badge-text">
|
||||
<span class="badge-label">Qobuz</span>
|
||||
<span class="badge-label">{ $t('platforms.qobuz') }</span>
|
||||
<span class="badge-label">Connect</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class={`qobuz-legal ${className}`}>
|
||||
{#if showBrandingLine}
|
||||
<p class="branding-line">
|
||||
This application uses the Qobuz API but is not certified by Qobuz.
|
||||
{$t('legal.brandingLine')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { tick, onMount } from 'svelte';
|
||||
import { Play, ListMusic, Plus, MoreHorizontal, ListPlus, Library, Share2 } from 'lucide-svelte';
|
||||
import { Play, ListMusic, Plus, Ellipsis, ListPlus, Library, Share2 } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import {
|
||||
openMenu as openGlobalMenu,
|
||||
@@ -257,7 +257,7 @@
|
||||
onclick={toggleMenu}
|
||||
title={$t('actions.moreOptions')}
|
||||
>
|
||||
<MoreHorizontal size={18} />
|
||||
<Ellipsis size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
if (tier === 'mp3') {
|
||||
return 'MP3';
|
||||
}
|
||||
return 'CD Quality';
|
||||
return $t('quality.cdQuality');
|
||||
});
|
||||
|
||||
const tierLabel = $derived.by(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { X, Search, Heart, MoreVertical, Trash2, ListPlus, Info } from 'lucide-svelte';
|
||||
import { X, Search, Heart, EllipsisVertical, Trash2, ListPlus, Info } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { cachedSrc } from '$lib/actions/cachedImage';
|
||||
import {
|
||||
@@ -316,7 +316,7 @@
|
||||
<div class="np-title-row">
|
||||
<span class="np-title">{currentTrack.title}</span>
|
||||
{#if currentTrack.parental_warning}
|
||||
<span class="explicit-badge" title="Explicit"></span>
|
||||
<span class="explicit-badge" title={ $t('library.explicit') }></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="np-artist">{currentTrack.artist}</div>
|
||||
@@ -369,7 +369,7 @@
|
||||
<div class="track-title-row">
|
||||
<span class="track-title">{queueTrack.title}</span>
|
||||
{#if queueTrack.parental_warning}
|
||||
<span class="explicit-badge" title="Explicit"></span>
|
||||
<span class="explicit-badge" title={ $t('library.explicit') }></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="track-artist">{queueTrack.artist}</div>
|
||||
@@ -381,7 +381,7 @@
|
||||
onclick={(e) => toggleTrackMenu(e, originalIndex)}
|
||||
title={$t('actions.more')}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
<EllipsisVertical size={16} />
|
||||
</button>
|
||||
{#if openMenuIndex === originalIndex}
|
||||
<div class="track-context-menu">
|
||||
@@ -444,7 +444,7 @@
|
||||
onclick={(e) => toggleHistoryMenu(e, track.id)}
|
||||
title={$t('actions.more')}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
<EllipsisVertical size={16} />
|
||||
</button>
|
||||
{#if openHistoryMenuId === track.id}
|
||||
<div class="track-context-menu">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { X, CheckCircle, Smartphone, Shield, Key, QrCode, Wifi } from 'lucide-svelte';
|
||||
import { X, CircleCheckBig, Smartphone, Shield, Key, QrCode, Wifi } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import WizardStepper, { type Step } from './wizard/WizardStepper.svelte';
|
||||
|
||||
@@ -139,19 +139,19 @@
|
||||
|
||||
<div class="feature-list">
|
||||
<div class="feature-item">
|
||||
<CheckCircle size={16} class="feature-check" />
|
||||
<CircleCheckBig size={16} class="feature-check" />
|
||||
<span>{$t('remoteControlWizard.welcome.feature1')}</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<CheckCircle size={16} class="feature-check" />
|
||||
<CircleCheckBig size={16} class="feature-check" />
|
||||
<span>{$t('remoteControlWizard.welcome.feature2')}</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<CheckCircle size={16} class="feature-check" />
|
||||
<CircleCheckBig size={16} class="feature-check" />
|
||||
<span>{$t('remoteControlWizard.welcome.feature3')}</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<CheckCircle size={16} class="feature-check" />
|
||||
<CircleCheckBig size={16} class="feature-check" />
|
||||
<span>{$t('remoteControlWizard.welcome.feature4')}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,7 +342,7 @@
|
||||
|
||||
{:else if currentStep === 'done'}
|
||||
<div class="done-content">
|
||||
<CheckCircle size={64} class="done-icon" />
|
||||
<CircleCheckBig size={64} class="done-icon" />
|
||||
|
||||
<p class="body-text pwa-hint">{$t('remoteControlWizard.done.pwaHint')}</p>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { tick, onMount } from 'svelte';
|
||||
import { Play, Heart, MoreHorizontal, ListPlus, Library, Share2, ListMusic } from 'lucide-svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { Play, Heart, Ellipsis, ListPlus, Library, Share2, ListMusic } from 'lucide-svelte';
|
||||
import PlaylistCollage from './PlaylistCollage.svelte';
|
||||
import type { Playlist } from '$lib/stores/searchState';
|
||||
import {
|
||||
@@ -182,10 +183,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="action-buttons">
|
||||
<button class="overlay-btn overlay-btn--minor" type="button" title="Add to favorites">
|
||||
<button class="overlay-btn overlay-btn--minor" type="button" title={ $t('actions.addToFavorites') }>
|
||||
<Heart size={18} />
|
||||
</button>
|
||||
<button class="overlay-btn" type="button" onclick={handlePlay} title="Play">
|
||||
<button class="overlay-btn" type="button" onclick={handlePlay} title={ $t('actions.play') }>
|
||||
<Play size={18} fill="white" />
|
||||
</button>
|
||||
<button
|
||||
@@ -193,9 +194,9 @@
|
||||
type="button"
|
||||
bind:this={menuTriggerRef}
|
||||
onclick={toggleMenu}
|
||||
title="More options"
|
||||
title={ $t('actions.moreOptions') }
|
||||
>
|
||||
<MoreHorizontal size={18} />
|
||||
<Ellipsis size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,7 +214,7 @@
|
||||
<div class="card-subtitle">
|
||||
<span class="owner">{playlist.owner.name}</span>
|
||||
<span class="separator">-</span>
|
||||
<span class="tracks">{playlist.tracks_count} tracks</span>
|
||||
<span class="tracks">{ $t('playlist.trackCount', { values: { count: playlist.tracks_count } }) }</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -221,18 +222,18 @@
|
||||
{#if menuOpen}
|
||||
<div class="playlist-menu" bind:this={menuEl} style={menuStyle} use:portal>
|
||||
<button class="menu-item" onclick={() => { onPlayNext?.(); closeMenu(); }}>
|
||||
<ListPlus size={14} /> <span>Play next</span>
|
||||
<ListPlus size={14} /> <span>{ $t('actions.playNext') }</span>
|
||||
</button>
|
||||
<button class="menu-item" onclick={() => { onPlayLater?.(); closeMenu(); }}>
|
||||
<ListMusic size={14} /> <span>Add to queue</span>
|
||||
<ListMusic size={14} /> <span>{ $t('actions.addToQueue') }</span>
|
||||
</button>
|
||||
<div class="menu-separator"></div>
|
||||
<button class="menu-item" onclick={() => { onCopyToLibrary?.(); closeMenu(); }}>
|
||||
<Library size={14} /> <span>Copy to library</span>
|
||||
<Library size={14} /> <span>{ $t('playlist.copyToLibrary') }</span>
|
||||
</button>
|
||||
<div class="menu-separator"></div>
|
||||
<button class="menu-item" onclick={() => { onShareQobuz?.(); closeMenu(); }}>
|
||||
<Share2 size={14} /> <span>Share (Qobuz)</span>
|
||||
<Share2 size={14} /> <span>{ $t('actions.shareQobuz') }</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { Search, HardDrive, Plus, RefreshCw, ChevronDown, ChevronUp, Heart, ListMusic, Import, Settings, MoreHorizontal, ArrowUpDown, ChevronRight, ChevronLeft, Folder, FolderPlus, X, User, Disc, Music, ShoppingBag, Eye, EyeOff, Pencil } from 'lucide-svelte';
|
||||
import { Search, HardDrive, Plus, RefreshCw, ChevronDown, ChevronUp, Heart, ListMusic, Import, Settings, Ellipsis, ArrowUpDown, ChevronRight, ChevronLeft, Folder, FolderPlus, X, User, Disc, Music, ShoppingBag, Eye, EyeOff, Pencil } from 'lucide-svelte';
|
||||
import type { FavoritesPreferences } from '$lib/types';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -135,7 +135,7 @@
|
||||
|
||||
// Favorites section state
|
||||
let favoritesExpanded = $state(false);
|
||||
let favoritesTabOrder = $state<string[]>(['tracks', 'albums', 'artists']);
|
||||
let favoritesTabOrder = $state<string[]>(['tracks', 'albums', 'artists', 'playlists']);
|
||||
let showFavoritesMenu = $state(false);
|
||||
let favoritesMenuPos = $state({ x: 0, y: 0 });
|
||||
|
||||
@@ -318,7 +318,7 @@
|
||||
}
|
||||
|
||||
const artists = Array.from(artistNames).slice(0, 5);
|
||||
const trackText = `${trackCount} ${trackCount === 1 ? 'Track' : 'Tracks'}`;
|
||||
const trackText = `${trackCount} ${trackCount === 1 ? $t('playlist.track') : $t('playlist.tracks')}`;
|
||||
|
||||
if (artists.length > 0) {
|
||||
return `${artists.join('\n')}\n${trackText}`;
|
||||
@@ -329,12 +329,12 @@
|
||||
console.debug('Failed to fetch playlist artists:', err);
|
||||
}
|
||||
|
||||
return `${trackCount} ${trackCount === 1 ? 'Track' : 'Tracks'}`;
|
||||
return `${trackCount} ${trackCount === 1 ? $t('playlist.track') : $t('playlist.tracks')}`;
|
||||
}
|
||||
|
||||
// Format track count text with proper plural
|
||||
function formatTrackCount(total: number, localCount: number): string {
|
||||
const plural = total === 1 ? 'Track' : 'Tracks';
|
||||
const plural = total === 1 ? $t('playlist.track') : $t('playlist.tracks');
|
||||
if (localCount > 0) {
|
||||
return `${total} ${plural} (${localCount} local)`;
|
||||
}
|
||||
@@ -933,8 +933,7 @@
|
||||
async function loadFavoritesPreferences() {
|
||||
try {
|
||||
const prefs = await invoke<FavoritesPreferences>('v2_get_favorites_preferences');
|
||||
// Filter out 'playlists' from tab order for sidebar display
|
||||
favoritesTabOrder = (prefs.tab_order || ['tracks', 'albums', 'artists']).filter(tab => tab !== 'playlists');
|
||||
favoritesTabOrder = prefs.tab_order || ['tracks', 'albums', 'artists', 'playlists'];
|
||||
} catch (err) {
|
||||
console.debug('[Sidebar] Failed to load favorites preferences:', err);
|
||||
}
|
||||
@@ -1514,6 +1513,8 @@
|
||||
<Disc size={14} />
|
||||
{:else if tab === 'tracks'}
|
||||
<Music size={14} />
|
||||
{:else if tab === 'playlists'}
|
||||
<ListMusic size={14} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</NavigationItem>
|
||||
@@ -1611,7 +1612,7 @@
|
||||
onclick={(e) => { e.stopPropagation(); toggleMenu(); }}
|
||||
title={$t('actions.more')}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
<Ellipsis size={14} />
|
||||
</button>
|
||||
<button class="icon-btn" onclick={() => { playlistsCollapsed = !playlistsCollapsed; saveSidebarCollapseState(); }} title={playlistsCollapsed ? $t('actions.open') : $t('actions.close')}>
|
||||
{#if playlistsCollapsed}
|
||||
@@ -1889,6 +1890,8 @@
|
||||
<Disc size={14} />
|
||||
{:else if tab === 'tracks'}
|
||||
<Music size={14} />
|
||||
{:else if tab === 'playlists'}
|
||||
<ListMusic size={14} />
|
||||
{/if}
|
||||
<span>{$t(`favorites.${tab}`)}</span>
|
||||
</button>
|
||||
@@ -2027,7 +2030,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="folder-popover-empty">
|
||||
No playlists
|
||||
{ $t('empty.noPlaylists') }
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
<button
|
||||
class="control-btn close"
|
||||
onclick={handleClose}
|
||||
title="Close"
|
||||
title={ $t('actions.close') }
|
||||
aria-label="Close window"
|
||||
style={btnStyle(controlsColors?.close)}
|
||||
data-tauri-drag-region="false"
|
||||
@@ -162,7 +162,7 @@
|
||||
<button
|
||||
class="control-btn minimize"
|
||||
onclick={handleMinimize}
|
||||
title="Minimize"
|
||||
title={ $t('player.minimize') }
|
||||
aria-label="Minimize window"
|
||||
style={btnStyle(controlsColors?.minimize)}
|
||||
data-tauri-drag-region="false"
|
||||
@@ -180,7 +180,7 @@
|
||||
<button
|
||||
class="control-btn minimize"
|
||||
onclick={handleMinimize}
|
||||
title="Minimize"
|
||||
title={ $t('player.minimize') }
|
||||
aria-label="Minimize window"
|
||||
style={btnStyle(controlsColors?.minimize)}
|
||||
data-tauri-drag-region="false"
|
||||
@@ -204,7 +204,7 @@
|
||||
<button
|
||||
class="control-btn close"
|
||||
onclick={handleClose}
|
||||
title="Close"
|
||||
title={ $t('actions.close') }
|
||||
aria-label="Close window"
|
||||
style={btnStyle(controlsColors?.close)}
|
||||
data-tauri-drag-region="false"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Heart, HardDrive, ChevronDown, User, Disc, Music, ShoppingBag } from 'lucide-svelte';
|
||||
import { Heart, HardDrive, ChevronDown, User, Disc, Music, ListMusic, ShoppingBag } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -18,7 +18,7 @@
|
||||
activeView,
|
||||
activeItemId,
|
||||
onNavigate,
|
||||
favoritesTabOrder = ['tracks', 'albums', 'artists'],
|
||||
favoritesTabOrder = ['tracks', 'albums', 'artists', 'playlists'],
|
||||
position = 'left',
|
||||
showDiscover = false,
|
||||
showFavorites = false,
|
||||
@@ -209,6 +209,8 @@
|
||||
<Disc size={12} />
|
||||
{:else if tab === 'artists'}
|
||||
<User size={12} />
|
||||
{:else if tab === 'playlists'}
|
||||
<ListMusic size={12} />
|
||||
{/if}
|
||||
<span>{$t(`favorites.${tab}`)}</span>
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { CheckCircle, AlertCircle, AlertTriangle, Info, Loader2, X } from 'lucide-svelte';
|
||||
import { CircleCheckBig, CircleAlert, TriangleAlert, Info, LoaderCircle, X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
message: string;
|
||||
@@ -23,13 +23,13 @@
|
||||
<div class="toast" class:success={type === 'success'} class:error={type === 'error'} class:info={type === 'info'} class:warning={type === 'warning'} class:buffering={type === 'buffering'}>
|
||||
<div class="icon">
|
||||
{#if type === 'success'}
|
||||
<CheckCircle size={20} />
|
||||
<CircleCheckBig size={20} />
|
||||
{:else if type === 'error'}
|
||||
<AlertCircle size={20} />
|
||||
<CircleAlert size={20} />
|
||||
{:else if type === 'warning'}
|
||||
<AlertTriangle size={20} />
|
||||
<TriangleAlert size={20} />
|
||||
{:else if type === 'buffering'}
|
||||
<Loader2 size={20} class="spinning" />
|
||||
<LoaderCircle size={20} class="spinning" />
|
||||
{:else}
|
||||
<Info size={20} />
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { X, Loader2 } from 'lucide-svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { X, LoaderCircle } from 'lucide-svelte';
|
||||
import type { TrackInfo, Performer } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -46,13 +47,17 @@
|
||||
parts.push(`${track.maximum_sampling_rate}kHz`);
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return track.hires_streamable ? 'Hi-Res' : 'Lossless';
|
||||
return track.hires_streamable ? $t('quality.hiRes') : $t('quality.lossless');
|
||||
}
|
||||
return parts.join(' / ');
|
||||
}
|
||||
|
||||
// Convert CamelCase role to display format
|
||||
function formatRole(role: string): string {
|
||||
return role[0].toLowerCase() + role.slice(1).replaceAll(' ','');
|
||||
}
|
||||
|
||||
function formatUnknownRole(role: string): string {
|
||||
return role.replace(/([A-Z])/g, ' $1').trim().toUpperCase();
|
||||
}
|
||||
|
||||
@@ -166,7 +171,7 @@
|
||||
<div class="track-modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
|
||||
{#if loading}
|
||||
<div class="loading-state">
|
||||
<Loader2 size={32} class="spinner" />
|
||||
<LoaderCircle size={32} class="spinner" />
|
||||
<span>Loading track info...</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
@@ -199,7 +204,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose} aria-label="Close">
|
||||
<button class="close-btn" onclick={onClose} aria-label={ $t('actions.close') }>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -209,11 +214,11 @@
|
||||
<!-- Metadata Grid (3 columns) -->
|
||||
<div class="metadata-grid">
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">DURATION</span>
|
||||
<span class="metadata-label">{$t('tracklist.duration')}</span>
|
||||
<span class="metadata-value">{formatDuration(trackInfo.track.duration)}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">QUALITY</span>
|
||||
<span class="metadata-label">{$t('tracklist.quality')}</span>
|
||||
<span class="metadata-value">{formatQuality(trackInfo.track)}</span>
|
||||
</div>
|
||||
{#if trackInfo.track.isrc}
|
||||
@@ -224,7 +229,7 @@
|
||||
{/if}
|
||||
{#if trackInfo.track.album?.label}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">LABEL</span>
|
||||
<span class="metadata-label">{$t('search.label')}</span>
|
||||
{#if onLabelClick}
|
||||
<button
|
||||
class="label-link"
|
||||
@@ -248,7 +253,7 @@
|
||||
<div class="credits-grid">
|
||||
{#each groupedCredits as { role, names }}
|
||||
<div class="credit-item">
|
||||
<span class="credit-label">{formatRole(role)}</span>
|
||||
<span class="credit-label">{ $t(`performerRoles.${formatRole(role)}`, { default: formatUnknownRole(role) }) }</span>
|
||||
<span class="credit-value">
|
||||
{#each names as name, i}
|
||||
{#if onMusicianClick || onPerformerSearch}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import {
|
||||
ChevronRight,
|
||||
MoreHorizontal,
|
||||
Ellipsis,
|
||||
Play,
|
||||
ListPlus,
|
||||
ListEnd,
|
||||
@@ -477,7 +478,7 @@
|
||||
}}
|
||||
aria-label="Track actions"
|
||||
>
|
||||
<MoreHorizontal size={18} />
|
||||
<Ellipsis size={18} />
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
@@ -500,31 +501,31 @@
|
||||
{#if onPlayNow}
|
||||
<button class="menu-item" onclick={() => handleAction(onPlayNow)}>
|
||||
<Play size={14} />
|
||||
<span>Play now</span>
|
||||
<span>{ $t('actions.playNow') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onPlayTrackOnly}
|
||||
<button class="menu-item" onclick={() => handleAction(onPlayTrackOnly)}>
|
||||
<Play size={14} />
|
||||
<span>Play track only</span>
|
||||
<span>{ $t('actions.playTrackOnly') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onPlayFromHere}
|
||||
<button class="menu-item" onclick={() => handleAction(onPlayFromHere)}>
|
||||
<Play size={14} />
|
||||
<span>Play from here</span>
|
||||
<span>{ $t('actions.playFromHere') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onPlayNext}
|
||||
<button class="menu-item" onclick={() => handleAction(onPlayNext)}>
|
||||
<ListPlus size={14} />
|
||||
<span>Play next</span>
|
||||
<span>{ $t('actions.playNext') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onPlayLater}
|
||||
<button class="menu-item" onclick={() => handleAction(onPlayLater)}>
|
||||
<ListEnd size={14} />
|
||||
<span>Add to queue</span>
|
||||
<span>{ $t('actions.addToQueue') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if hasRadio}
|
||||
@@ -548,7 +549,7 @@
|
||||
}}
|
||||
>
|
||||
<Radio size={14} />
|
||||
<span>Create radio</span>
|
||||
<span>{ $t('radio.createRadio') }</span>
|
||||
<ChevronRight size={14} class="chevron" />
|
||||
{#if radioOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -563,11 +564,11 @@
|
||||
>
|
||||
<button class="menu-item" onclick={() => handleAction(onCreateQbzRadio)}>
|
||||
<Radio size={14} />
|
||||
<span>QBZ Radio</span>
|
||||
<span>{ $t('radio.qbzRadio') }</span>
|
||||
</button>
|
||||
<button class="menu-item" onclick={() => handleAction(onCreateQobuzRadio)}>
|
||||
<Radio size={14} />
|
||||
<span>Qobuz Radio</span>
|
||||
<span>{ $t('radio.qobuzRadio') }</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -575,7 +576,7 @@
|
||||
{:else}
|
||||
<button class="menu-item" onclick={() => handleAction(onCreateQbzRadio || onCreateQobuzRadio)}>
|
||||
<Radio size={14} />
|
||||
<span>Create radio</span>
|
||||
<span>{ $t('radio.createRadio') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -589,25 +590,25 @@
|
||||
{#if onAddFavorite}
|
||||
<button class="menu-item" onclick={() => handleAction(onAddFavorite)}>
|
||||
<Heart size={14} />
|
||||
<span>Add to favorites</span>
|
||||
<span>{ $t('actions.addToFavorites') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onAddToPlaylist && !shouldHidePlaylistFeatures()}
|
||||
<button class="menu-item" onclick={() => handleAction(onAddToPlaylist)}>
|
||||
<ListMusic size={14} />
|
||||
<span>Add to playlist</span>
|
||||
<span>{ $t('actions.addToPlaylist') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onRemoveFromPlaylist}
|
||||
<button class="menu-item danger" onclick={() => handleAction(onRemoveFromPlaylist)}>
|
||||
<Trash2 size={14} />
|
||||
<span>Remove from playlist</span>
|
||||
<span>{ $t('actions.removeFromPlaylist') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onFindReplacement}
|
||||
<button class="menu-item" onclick={() => handleAction(onFindReplacement)}>
|
||||
<Search size={14} />
|
||||
<span>Find available version</span>
|
||||
<span>{ $t('actions.findAvailableVersion') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -636,7 +637,7 @@
|
||||
}}
|
||||
>
|
||||
<Share2 size={14} />
|
||||
<span>Share</span>
|
||||
<span>{ $t('actions.share') }</span>
|
||||
<ChevronRight size={14} class="chevron" />
|
||||
{#if shareOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -652,7 +653,7 @@
|
||||
{#if onShareQobuz}
|
||||
<button class="menu-item" onclick={() => handleAction(onShareQobuz)}>
|
||||
<Link size={14} />
|
||||
<span>Qobuz™ link</span>
|
||||
<span>{ $t('actions.qobuzLink') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onShareSonglink}
|
||||
@@ -691,7 +692,7 @@
|
||||
}}
|
||||
>
|
||||
<CloudDownload size={14} />
|
||||
<span>Make available offline</span>
|
||||
<span>{ $t('actions.makeAvailableOffline') }</span>
|
||||
<ChevronRight size={14} class="chevron" />
|
||||
{#if downloadOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -707,13 +708,13 @@
|
||||
{#if onReDownload}
|
||||
<button class="menu-item" onclick={() => handleAction(onReDownload)}>
|
||||
<RefreshCw size={14} />
|
||||
<span>Refresh offline copy</span>
|
||||
<span>{ $t('actions.refreshOfflineCopy') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onRemoveDownload}
|
||||
<button class="menu-item danger" onclick={() => handleAction(onRemoveDownload)}>
|
||||
<Trash2 size={14} />
|
||||
<span>Remove offline copy</span>
|
||||
<span>{ $t('actions.removeOfflineCopy') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -722,7 +723,7 @@
|
||||
{:else}
|
||||
<button class="menu-item" onclick={() => handleAction(onDownload)}>
|
||||
<CloudDownload size={14} />
|
||||
<span>Make available offline</span>
|
||||
<span>{ $t('actions.makeAvailableOffline') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -735,19 +736,19 @@
|
||||
{#if onGoToAlbum}
|
||||
<button class="menu-item" onclick={() => handleAction(onGoToAlbum)}>
|
||||
<Disc3 size={14} />
|
||||
<span>Go to album</span>
|
||||
<span>{ $t('actions.goToAlbum') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onGoToArtist}
|
||||
<button class="menu-item" onclick={() => handleAction(onGoToArtist)}>
|
||||
<User size={14} />
|
||||
<span>Go to artist</span>
|
||||
<span>{ $t('actions.goToArtist') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onShowInfo}
|
||||
<button class="menu-item" onclick={() => handleAction(onShowInfo)}>
|
||||
<Info size={14} />
|
||||
<span>Track info</span>
|
||||
<span>{ $t('actions.trackInfo') }</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { X, Search, Play, Check, AlertCircle, Loader2 } from 'lucide-svelte';
|
||||
import { X, Search, Play, Check, CircleAlert, LoaderCircle } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface SearchTrack {
|
||||
@@ -138,7 +138,7 @@
|
||||
|
||||
<div class="search-section">
|
||||
<div class="search-info">
|
||||
<AlertCircle size={16} />
|
||||
<CircleAlert size={16} />
|
||||
<span>{$t('playlist.searchingFor')}: <strong>{trackTitle}</strong>{trackArtist ? ` - ${trackArtist}` : ''}</span>
|
||||
</div>
|
||||
<div class="search-input-container">
|
||||
@@ -152,7 +152,7 @@
|
||||
/>
|
||||
<button class="search-btn" onclick={doSearch} disabled={loading}>
|
||||
{#if loading}
|
||||
<Loader2 size={16} class="spinner" />
|
||||
<LoaderCircle size={16} class="spinner" />
|
||||
{:else}
|
||||
{$t('playlist.searchButton')}
|
||||
{/if}
|
||||
@@ -163,12 +163,12 @@
|
||||
<div class="results-section">
|
||||
{#if loading}
|
||||
<div class="loading-state">
|
||||
<Loader2 size={24} class="spinner" />
|
||||
<LoaderCircle size={24} class="spinner" />
|
||||
<span>{$t('playlist.loading')}</span>
|
||||
</div>
|
||||
{:else if searched && results.length === 0}
|
||||
<div class="empty-state">
|
||||
<AlertCircle size={32} />
|
||||
<CircleAlert size={32} />
|
||||
<p>{$t('playlist.noReplacementsFound')}</p>
|
||||
<p class="hint">{$t('playlist.tryDifferentSearch')}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Play, Pause, Heart, HardDrive, AlertCircle, Ban, Music } from 'lucide-svelte';
|
||||
import { Play, Pause, Heart, HardDrive, CircleAlert, Ban, Music } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { cachedSrc } from '$lib/actions/cachedImage';
|
||||
import TrackMenu from './TrackMenu.svelte';
|
||||
@@ -197,7 +197,7 @@
|
||||
</span>
|
||||
{:else if isUnavailable}
|
||||
<span class="unavailable-icon" title={unavailableTooltip}>
|
||||
<AlertCircle size={16} />
|
||||
<CircleAlert size={16} />
|
||||
</span>
|
||||
{:else if isActiveTrack || isPlaying}
|
||||
{#if isHovered}
|
||||
@@ -243,7 +243,7 @@
|
||||
<div class="track-title-row">
|
||||
<span class="track-title" class:active={isActiveTrack || isPlaying}>{title}</span>
|
||||
{#if explicit}
|
||||
<span class="explicit-badge" title="Explicit"></span>
|
||||
<span class="explicit-badge" title={ $t('library.explicit') }></span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if artist && !compact}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Settings, SlidersHorizontal, Keyboard, Bug, BookOpen, HelpCircle } from 'lucide-svelte';
|
||||
import { Settings, SlidersHorizontal, Keyboard, Bug, BookOpen, CircleHelp } from 'lucide-svelte';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
<span>{$t('settings.about.reportIssue')}</span>
|
||||
</button>
|
||||
<button class="menu-item" onclick={handleAboutClick}>
|
||||
<HelpCircle size={16} />
|
||||
<CircleHelp size={16} />
|
||||
<span>{$t('settings.about.title')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Mic2, Upload } from 'lucide-svelte';
|
||||
import { MicVocal, Upload } from 'lucide-svelte';
|
||||
import { restoreScrollOnBackForward } from '$lib/utils/scrollRestore';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -242,7 +242,7 @@
|
||||
{#if artistImage}
|
||||
<img src={artistImage} alt={displayName} class="artist-image" loading="lazy" />
|
||||
{:else}
|
||||
<Mic2 size={32} />
|
||||
<MicVocal size={32} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if showSettings}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Mic2 } from 'lucide-svelte';
|
||||
import { MicVocal } from 'lucide-svelte';
|
||||
import { restoreScrollOnBackForward } from '$lib/utils/scrollRestore';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -219,7 +219,7 @@
|
||||
{#if artistImage}
|
||||
<img src={artistImage} alt={displayName} loading="lazy" />
|
||||
{:else}
|
||||
<Mic2 size={20} />
|
||||
<MicVocal size={20} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="artist-info">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { Disc3, Loader2 } from 'lucide-svelte';
|
||||
import { Disc3, LoaderCircle } from 'lucide-svelte';
|
||||
import AlbumCard from './AlbumCard.svelte';
|
||||
import QualityBadge from './QualityBadge.svelte';
|
||||
import { restoreScrollOnBackForward } from '$lib/utils/scrollRestore';
|
||||
@@ -374,7 +374,7 @@
|
||||
</div>
|
||||
{#if isLoadingMore}
|
||||
<div class="loading-more">
|
||||
<Loader2 size={20} class="spin" />
|
||||
<LoaderCircle size={20} class="spin" />
|
||||
<span>{$t('discover.loadingMore')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, type Snippet } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { Mic2 } from 'lucide-svelte';
|
||||
import { MicVocal } from 'lucide-svelte';
|
||||
import { cachedSrc } from '$lib/actions/cachedImage';
|
||||
import { restoreScrollOnBackForward } from '$lib/utils/scrollRestore';
|
||||
|
||||
@@ -274,7 +274,7 @@
|
||||
<img use:cachedSrc={artist.image?.small || artist.image?.thumbnail} alt={artist.name} loading="lazy" decoding="async" />
|
||||
{:else}
|
||||
<div class="artist-placeholder">
|
||||
<Mic2 size={32} />
|
||||
<MicVocal size={32} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { Mic2 } from 'lucide-svelte';
|
||||
import { MicVocal } from 'lucide-svelte';
|
||||
import { cachedSrc } from '$lib/actions/cachedImage';
|
||||
import { restoreScrollOnBackForward } from '$lib/utils/scrollRestore';
|
||||
|
||||
@@ -254,7 +254,7 @@
|
||||
<img use:cachedSrc={artist.image?.thumbnail || artist.image?.small} alt={artist.name} loading="lazy" decoding="async" />
|
||||
{:else}
|
||||
<div class="artist-list-placeholder">
|
||||
<Mic2 size={20} />
|
||||
<MicVocal size={20} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
/* Floating variant (default) */
|
||||
.artwork-wrapper {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
max-width: min(42vw, 72vh);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
@@ -55,7 +55,7 @@
|
||||
.vinyl-disc {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
max-width: min(42vw, 72vh);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
@@ -104,14 +104,14 @@
|
||||
@media (max-width: 1200px) {
|
||||
.artwork-wrapper,
|
||||
.vinyl-disc {
|
||||
max-width: 320px;
|
||||
max-width: min(40vw, 65vh);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.artwork-wrapper,
|
||||
.vinyl-disc {
|
||||
max-width: 280px;
|
||||
max-width: min(55vw, 35vh);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Shuffle, SkipBack, Play, Pause, SkipForward, Repeat, Heart } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import ProgressBar from './shared/ProgressBar.svelte';
|
||||
import VolumeSlider from './shared/VolumeSlider.svelte';
|
||||
import QualityBadge from '../QualityBadge.svelte';
|
||||
@@ -61,7 +62,7 @@
|
||||
class="control-btn secondary"
|
||||
class:active={isShuffle}
|
||||
onclick={onToggleShuffle}
|
||||
title="Shuffle"
|
||||
title={$t('player.shuffle')}
|
||||
>
|
||||
<Shuffle size={18} />
|
||||
</button>
|
||||
@@ -70,7 +71,7 @@
|
||||
class="control-btn"
|
||||
onclick={onSkipBack}
|
||||
disabled={!onSkipBack}
|
||||
title="Previous"
|
||||
title={$t('player.previous')}
|
||||
>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
@@ -78,7 +79,7 @@
|
||||
<button
|
||||
class="control-btn play-btn"
|
||||
onclick={onTogglePlay}
|
||||
title={isPlaying ? 'Pause' : 'Play'}
|
||||
title={isPlaying ? $t('player.pause') : $t('player.play')}
|
||||
>
|
||||
{#if isPlaying}
|
||||
<Pause size={24} />
|
||||
@@ -91,7 +92,7 @@
|
||||
class="control-btn"
|
||||
onclick={onSkipForward}
|
||||
disabled={!onSkipForward}
|
||||
title="Next"
|
||||
title={$t('player.next')}
|
||||
>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
@@ -100,7 +101,7 @@
|
||||
class="control-btn secondary"
|
||||
class:active={repeatMode !== 'off'}
|
||||
onclick={onToggleRepeat}
|
||||
title={repeatMode === 'off' ? 'Repeat' : repeatMode === 'all' ? 'Repeat All' : 'Repeat One'}
|
||||
title={repeatMode === 'off' ? $t('player.repeat') : repeatMode === 'all' ? $t('player.repeatAll') : $t('player.repeatOne')}
|
||||
>
|
||||
<Repeat size={18} />
|
||||
{#if repeatMode === 'one'}
|
||||
@@ -120,7 +121,7 @@
|
||||
class="action-btn"
|
||||
class:active={isFavorite}
|
||||
onclick={onToggleFavorite}
|
||||
title={isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
|
||||
title={isFavorite ? $t('actions.removeFromFavorites') : $t('actions.addToFavorites')}
|
||||
>
|
||||
<Heart
|
||||
size={18}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Disc3, Disc, Mic2, ListMusic, Music2, Info, Radio, Maximize, Minimize, ChevronDown, X, Square, Copy, Minus, Image, Activity, AudioWaveform, CircleDot, Crosshair, Zap, HeartPulse, Move } from 'lucide-svelte';
|
||||
import { Disc3, Disc, MicVocal, ListMusic, Music2, Info, Radio, Maximize, Minimize, ChevronDown, X, Square, Copy, Minus, Image, Activity, AudioWaveform, CircleDot, Crosshair, Zap, HeartPulse, Move } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
];
|
||||
|
||||
const focusTabsBottom: { id: FocusTab; labelKey: string; icon: typeof Disc3 }[] = [
|
||||
{ id: 'lyrics-focus', labelKey: 'settings.appearance.immersiveViews.lyrics-focus', icon: Mic2 },
|
||||
{ id: 'lyrics-focus', labelKey: 'settings.appearance.immersiveViews.lyrics-focus', icon: MicVocal },
|
||||
{ id: 'queue-focus', labelKey: 'settings.appearance.immersiveViews.queue-focus', icon: ListMusic },
|
||||
];
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
<button
|
||||
class="mode-toggle"
|
||||
onclick={() => onViewModeChange(isFocusMode ? 'split' : 'focus')}
|
||||
title={isFocusMode ? 'Switch to Split View (V)' : 'Switch to Immersive View (V)'}
|
||||
title={isFocusMode ? $t('actions.switchToSplitView') : $t('actions.switchToImmersiveView')}
|
||||
>
|
||||
<img
|
||||
src={isFocusMode ? '/split-view.svg' : '/lotus.svg'}
|
||||
@@ -336,7 +336,7 @@
|
||||
<button
|
||||
class="window-btn"
|
||||
onclick={onToggleFullscreen}
|
||||
title={isFullscreen ? 'Exit Fullscreen (F11)' : 'Fullscreen (F11)'}
|
||||
title={isFullscreen ? $t('player.exitFullscreenWithKey', { values: { key: "F11" } }) : $t('player.fullscreenWithKey', { values: { key: "F11" } })}
|
||||
>
|
||||
{#if isFullscreen}
|
||||
<Minimize size={16} />
|
||||
@@ -347,7 +347,7 @@
|
||||
<button
|
||||
class="window-btn"
|
||||
onclick={onToggleMaximize}
|
||||
title={isMaximized ? 'Restore Window' : 'Maximize Window'}
|
||||
title={isMaximized ? $t('player.restoreWindow') : $t('player.maximizeWindow')}
|
||||
>
|
||||
{#if isMaximized}
|
||||
<Copy size={14} />
|
||||
@@ -358,28 +358,28 @@
|
||||
<button
|
||||
class="window-btn"
|
||||
onclick={onMinimize}
|
||||
title="Minimize"
|
||||
title={$t('player.minimize')}
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<button
|
||||
class="window-btn"
|
||||
onclick={onClose}
|
||||
title="Exit Immersive (Esc)"
|
||||
title={$t('player.exitImmersiveWithKey', { values: { key: $t('keys.esc') } })}
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<button
|
||||
class="window-btn close"
|
||||
onclick={handleCloseApp}
|
||||
title="Close App"
|
||||
title={$t('player.closeApp')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Default icon (window) -->
|
||||
<button class="window-trigger" title="Window Controls">
|
||||
<button class="window-trigger" title="{ $t('player.windowControls') }">
|
||||
<img src="/window.svg" alt="Window" class="window-icon" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user