fix: add missing CSS import and fix UI issues

- Add app.css import to +layout.svelte (fixes broken/light UI)
- Add logging for audio debugging
- Add image fallbacks for album and track artwork
- Fix sidebar/main-content layout (flex-shrink, min-width)
- Remove placeholder data from HomeView
- Add LoginView and SearchView components
- Update window title and branding to QBZ
This commit is contained in:
vicrodh committed 2026-01-09 08:47:53 -06:00
1 parent cdfd3263de
commit 3a0e3f26d5
22 files changed
+1950 -298

No files matched your search

+3 -3
View File
@@ -45,9 +45,9 @@ anyhow = "1"
log = "0.4"
env_logger = "0.11"
# Audio playback (will expand later)
# rodio = "0.19"
# cpal = "0.15"
# Audio playback
rodio = { version = "0.19", features = ["symphonia-all"] }
symphonia = { version = "0.5", features = ["all"] }
# Storage
# sled = "0.34"
+39 -8
View File
@@ -1,6 +1,6 @@
//! Qobuz API client implementation
use reqwest::{header, Client, StatusCode};
use reqwest::{Client, StatusCode};
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::RwLock;
@@ -106,10 +106,9 @@ impl QobuzClient {
let url = endpoints::build_url(paths::USER_LOGIN);
let response = self
.http
.post(&url)
.get(&url)
.header("X-App-Id", self.app_id().await?)
.header(header::CONTENT_TYPE, "application/json")
.form(&[("email", email), ("password", password)])
.query(&[("email", email), ("password", password)])
.send()
.await?;
@@ -133,6 +132,18 @@ impl QobuzClient {
self.session.read().await.is_some()
}
/// Logout - clear the session
pub async fn logout(&self) {
*self.session.write().await = None;
}
/// Get current user info (display name and subscription)
pub async fn get_user_info(&self) -> Option<(String, String)> {
self.session.read().await.as_ref().map(|s| {
(s.display_name.clone(), s.subscription_label.clone())
})
}
/// Get user auth token header value
async fn auth_token(&self) -> Result<String> {
self.session
@@ -280,11 +291,15 @@ impl QobuzClient {
/// Get stream URL for a track (requires auth + signature)
pub async fn get_stream_url(&self, track_id: u64, quality: Quality) -> Result<StreamUrl> {
log::info!("Getting stream URL for track {} with quality {:?}", track_id, quality);
let url = endpoints::build_url(paths::TRACK_GET_FILE_URL);
let timestamp = get_timestamp();
log::debug!("Getting secret for signing...");
let secret = self.secret().await?;
log::debug!("Secret obtained, signing request...");
let signature = sign_get_file_url(track_id, quality.id(), timestamp, &secret);
log::debug!("Sending stream URL request...");
let response = self
.http
.get(&url)
@@ -300,6 +315,7 @@ impl QobuzClient {
.send()
.await?;
log::info!("Stream URL response status: {}", response.status());
match response.status() {
StatusCode::OK => {
let json: Value = response.json().await?;
@@ -331,18 +347,33 @@ impl QobuzClient {
track_id: u64,
preferred: Quality,
) -> Result<StreamUrl> {
log::info!("Getting stream URL with fallback for track {}, preferred quality: {:?}", track_id, preferred);
let qualities = Quality::fallback_order();
let start_idx = qualities.iter().position(|q| *q == preferred).unwrap_or(0);
for quality in &qualities[start_idx..] {
log::info!("Trying quality: {:?}", quality);
match self.get_stream_url(track_id, *quality).await {
Ok(url) if !url.has_restrictions() => return Ok(url),
Ok(_) => continue, // Format restricted, try lower
Err(ApiError::InvalidAppSecret) => return Err(ApiError::InvalidAppSecret),
Err(_) => continue,
Ok(url) if !url.has_restrictions() => {
log::info!("Got stream URL successfully: {} (format: {})", url.url, url.mime_type);
return Ok(url);
},
Ok(_) => {
log::info!("Quality {:?} has restrictions, trying next", quality);
continue;
},
Err(ApiError::InvalidAppSecret) => {
log::error!("Invalid app secret");
return Err(ApiError::InvalidAppSecret);
},
Err(e) => {
log::warn!("Quality {:?} failed: {}, trying next", quality, e);
continue;
},
}
}
log::error!("No quality available for track {}", track_id);
Err(ApiError::NoQualityAvailable)
}
+22
View File
@@ -50,3 +50,25 @@ pub async fn init_client(state: State<'_, AppState>) -> Result<bool, String> {
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
pub async fn logout(state: State<'_, AppState>) -> Result<(), String> {
let client = state.client.lock().await;
client.logout().await;
Ok(())
}
#[derive(serde::Serialize)]
pub struct UserInfo {
pub user_name: String,
pub subscription: String,
}
#[tauri::command]
pub async fn get_user_info(state: State<'_, AppState>) -> Result<Option<UserInfo>, String> {
let client = state.client.lock().await;
Ok(client.get_user_info().await.map(|(name, sub)| UserInfo {
user_name: name,
subscription: sub,
}))
}
+2
View File
@@ -3,7 +3,9 @@
//! Exposes backend functionality to the frontend via IPC
pub mod auth;
pub mod playback;
pub mod search;
pub use auth::*;
pub use playback::*;
pub use search::*;
+62
View File
@@ -0,0 +1,62 @@
//! Playback-related Tauri commands
use tauri::State;
use crate::api::models::Quality;
use crate::player::PlaybackState;
use crate::AppState;
/// Play a track by ID
#[tauri::command]
pub async fn play_track(track_id: u64, state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: play_track {}", track_id);
let client = state.client.lock().await;
// Default to Hi-Res quality, will fallback if not available
state
.player
.play_track(&client, track_id, Quality::HiRes)
.await
}
/// Pause playback
#[tauri::command]
pub fn pause_playback(state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: pause_playback");
state.player.pause()
}
/// Resume playback
#[tauri::command]
pub fn resume_playback(state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: resume_playback");
state.player.resume()
}
/// Stop playback
#[tauri::command]
pub fn stop_playback(state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: stop_playback");
state.player.stop()
}
/// Set volume (0.0 - 1.0)
#[tauri::command]
pub fn set_volume(volume: f32, state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: set_volume {}", volume);
state.player.set_volume(volume)
}
/// Seek to position in seconds
#[tauri::command]
pub fn seek(position: u64, state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: seek {}", position);
state.player.seek(position)
}
/// Get current playback state
#[tauri::command]
pub fn get_playback_state(state: State<'_, AppState>) -> Result<PlaybackState, String> {
state.player.get_state()
}
+3 -9
View File
@@ -2,7 +2,7 @@
use tauri::State;
use crate::api::{Album, Artist, Track, SearchResultsPage};
use crate::api::{Album, Artist, SearchResultsPage, Track};
use crate::AppState;
#[tauri::command]
@@ -45,19 +45,13 @@ pub async fn search_artists(
}
#[tauri::command]
pub async fn get_album(
album_id: String,
state: State<'_, AppState>,
) -> Result<Album, String> {
pub async fn get_album(album_id: String, state: State<'_, AppState>) -> Result<Album, String> {
let client = state.client.lock().await;
client.get_album(&album_id).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_track(
track_id: u64,
state: State<'_, AppState>,
) -> Result<Track, String> {
pub async fn get_track(track_id: u64, state: State<'_, AppState>) -> Result<Track, String> {
let client = state.client.lock().await;
client.get_track(track_id).await.map_err(|e| e.to_string())
}
+20
View File
@@ -13,16 +13,19 @@ use std::sync::Arc;
use tokio::sync::Mutex;
use api::QobuzClient;
use player::Player;
/// Application state shared across commands
pub struct AppState {
pub client: Arc<Mutex<QobuzClient>>,
pub player: Player,
}
impl AppState {
pub fn new() -> Self {
Self {
client: Arc::new(Mutex::new(QobuzClient::default())),
player: Player::new(),
}
}
}
@@ -35,6 +38,13 @@ impl Default for AppState {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Initialize logging
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.format_timestamp_millis()
.init();
log::info!("QBZ starting...");
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(AppState::new())
@@ -42,13 +52,23 @@ pub fn run() {
// Auth commands
commands::init_client,
commands::login,
commands::logout,
commands::is_logged_in,
commands::get_user_info,
// Search commands
commands::search_albums,
commands::search_tracks,
commands::search_artists,
commands::get_album,
commands::get_track,
// Playback commands
commands::play_track,
commands::pause_playback,
commands::resume_playback,
commands::stop_playback,
commands::set_volume,
commands::seek,
commands::get_playback_state,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+366 -7
View File
@@ -1,12 +1,371 @@
//! Audio player module
//!
//! Handles audio playback with support for:
//! - Multiple backends (PipeWire, ALSA direct, PulseAudio)
//! - HTTP streaming from Qobuz
//! - FLAC, MP3 decoding via symphonia
//! - Gapless playback
//! - DAC passthrough mode
//! - Sample rate reporting
//! - Volume control
//!
//! Uses a dedicated audio thread since rodio's OutputStream is not Send.
// TODO: Implement audio playback
// pub mod engine;
// pub mod output;
// pub mod passthrough;
use std::io::{BufReader, Cursor};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, Sender};
use std::sync::Arc;
use std::thread;
use rodio::{Decoder, OutputStream, Sink, Source};
use crate::api::{client::QobuzClient, models::Quality};
/// Commands sent to the audio thread
enum AudioCommand {
/// Play audio data with track ID
Play { data: Vec<u8>, track_id: u64 },
/// Pause playback
Pause,
/// Resume playback
Resume,
/// Stop playback
Stop,
/// Set volume (0.0 - 1.0)
SetVolume(f32),
}
/// Shared state between main thread and audio thread
#[derive(Clone)]
pub struct SharedState {
/// Is currently playing
is_playing: Arc<AtomicBool>,
/// Current position in seconds
position: Arc<AtomicU64>,
/// Total duration in seconds
duration: Arc<AtomicU64>,
/// Current track ID
current_track_id: Arc<AtomicU64>,
/// Volume (0.0 - 1.0 stored as 0-100)
volume: Arc<AtomicU64>,
}
impl Default for SharedState {
fn default() -> Self {
Self::new()
}
}
impl SharedState {
pub fn new() -> Self {
Self {
is_playing: Arc::new(AtomicBool::new(false)),
position: Arc::new(AtomicU64::new(0)),
duration: Arc::new(AtomicU64::new(0)),
current_track_id: Arc::new(AtomicU64::new(0)),
volume: Arc::new(AtomicU64::new(75)),
}
}
pub fn is_playing(&self) -> bool {
self.is_playing.load(Ordering::SeqCst)
}
pub fn position(&self) -> u64 {
self.position.load(Ordering::SeqCst)
}
pub fn duration(&self) -> u64 {
self.duration.load(Ordering::SeqCst)
}
pub fn current_track_id(&self) -> u64 {
self.current_track_id.load(Ordering::SeqCst)
}
pub fn volume(&self) -> f32 {
self.volume.load(Ordering::SeqCst) as f32 / 100.0
}
}
/// Audio player that handles streaming playback
/// Uses a dedicated thread for audio output
pub struct Player {
/// Channel to send commands to the audio thread
tx: Sender<AudioCommand>,
/// Shared state accessible from any thread
pub state: SharedState,
}
impl Default for Player {
fn default() -> Self {
Self::new()
}
}
impl Player {
pub fn new() -> Self {
let (tx, rx) = mpsc::channel::<AudioCommand>();
let state = SharedState::new();
let thread_state = state.clone();
// Spawn dedicated audio thread
thread::spawn(move || {
log::info!("Audio thread starting...");
// Create output stream on this thread (it must stay here)
let (_stream, stream_handle) = match OutputStream::try_default() {
Ok(s) => {
log::info!("Audio output initialized successfully");
s
}
Err(e) => {
log::error!("Failed to create audio output: {}. Audio playback will not work.", e);
// Keep the thread alive to receive commands (even if we can't play)
// This prevents the channel from closing immediately
loop {
match rx.recv() {
Ok(_) => {
log::warn!("Audio command received but audio output is unavailable");
}
Err(_) => {
log::info!("Audio thread: channel closed, exiting");
break;
}
}
}
return;
}
};
let mut current_sink: Option<Sink> = None;
log::info!("Audio thread ready and waiting for commands");
loop {
match rx.recv() {
Ok(AudioCommand::Play { data, track_id }) => {
log::info!("Audio thread: playing track {}", track_id);
// Stop existing playback
if let Some(sink) = current_sink.take() {
sink.stop();
}
// Create new sink
let sink = match Sink::try_new(&stream_handle) {
Ok(s) => s,
Err(e) => {
log::error!("Failed to create sink: {}", e);
continue;
}
};
// Set volume
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
sink.set_volume(volume);
// Decode audio
let cursor = Cursor::new(data);
let source = match Decoder::new(BufReader::new(cursor)) {
Ok(s) => s,
Err(e) => {
log::error!("Failed to decode audio: {}", e);
continue;
}
};
// Get duration if available
if let Some(duration) = source.total_duration() {
thread_state
.duration
.store(duration.as_secs(), Ordering::SeqCst);
}
sink.append(source);
// Update state
thread_state.is_playing.store(true, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst);
thread_state.current_track_id.store(track_id, Ordering::SeqCst);
current_sink = Some(sink);
log::info!("Audio thread: playback started");
}
Ok(AudioCommand::Pause) => {
if let Some(ref sink) = current_sink {
sink.pause();
thread_state.is_playing.store(false, Ordering::SeqCst);
log::info!("Audio thread: paused");
}
}
Ok(AudioCommand::Resume) => {
if let Some(ref sink) = current_sink {
sink.play();
thread_state.is_playing.store(true, Ordering::SeqCst);
log::info!("Audio thread: resumed");
}
}
Ok(AudioCommand::Stop) => {
if let Some(sink) = current_sink.take() {
sink.stop();
}
thread_state.is_playing.store(false, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst);
log::info!("Audio thread: stopped");
}
Ok(AudioCommand::SetVolume(volume)) => {
thread_state
.volume
.store((volume * 100.0) as u64, Ordering::SeqCst);
if let Some(ref sink) = current_sink {
sink.set_volume(volume);
}
log::info!("Audio thread: volume set to {}", volume);
}
Err(_) => {
// Channel closed, exit thread
log::info!("Audio thread: channel closed, exiting");
break;
}
}
}
});
Self { tx, state }
}
/// Play a track by ID
pub async fn play_track(
&self,
client: &QobuzClient,
track_id: u64,
quality: Quality,
) -> Result<(), String> {
log::info!("Player: Starting playback for track {} with quality {:?}", track_id, quality);
// Get the stream URL
log::info!("Player: Getting stream URL...");
let stream_url = client
.get_stream_url_with_fallback(track_id, quality)
.await
.map_err(|e| {
log::error!("Player: Failed to get stream URL: {}", e);
format!("Failed to get stream URL: {}", e)
})?;
log::info!("Player: Got stream URL: {} (format: {})", stream_url.url, stream_url.mime_type);
// Download the audio data
log::info!("Player: Starting audio download...");
let audio_data = self.download_audio(&stream_url.url).await.map_err(|e| {
log::error!("Player: Download failed: {}", e);
e
})?;
log::info!("Player: Downloaded {} bytes of audio data", audio_data.len());
// Send to audio thread
log::info!("Player: Sending to audio thread...");
self.tx
.send(AudioCommand::Play {
data: audio_data,
track_id,
})
.map_err(|e| {
log::error!("Player: Failed to send to audio thread: {}", e);
format!("Failed to send play command (audio thread may have crashed): {}", e)
})?;
log::info!("Player: Playback initiated successfully");
Ok(())
}
/// Download audio from URL with timeout
async fn download_audio(&self, url: &str) -> Result<Vec<u8>, String> {
use std::time::Duration;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
log::info!("Downloading audio from URL...");
let response = client
.get(url)
.header("User-Agent", "Mozilla/5.0")
.send()
.await
.map_err(|e| format!("Failed to fetch audio: {}", e))?;
if !response.status().is_success() {
return Err(format!("HTTP error: {}", response.status()));
}
log::info!("Download response received, reading bytes...");
let bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read audio bytes: {}", e))?;
log::info!("Downloaded {} bytes", bytes.len());
Ok(bytes.to_vec())
}
/// Pause playback
pub fn pause(&self) -> Result<(), String> {
self.tx
.send(AudioCommand::Pause)
.map_err(|e| format!("Failed to send pause command: {}", e))
}
/// Resume playback
pub fn resume(&self) -> Result<(), String> {
self.tx
.send(AudioCommand::Resume)
.map_err(|e| format!("Failed to send resume command: {}", e))
}
/// Stop playback
pub fn stop(&self) -> Result<(), String> {
self.tx
.send(AudioCommand::Stop)
.map_err(|e| format!("Failed to send stop command: {}", e))
}
/// Set volume (0.0 - 1.0)
pub fn set_volume(&self, volume: f32) -> Result<(), String> {
let clamped = volume.clamp(0.0, 1.0);
self.tx
.send(AudioCommand::SetVolume(clamped))
.map_err(|e| format!("Failed to send volume command: {}", e))
}
/// Seek to position (not supported for streaming, but we track position)
pub fn seek(&self, _position: u64) -> Result<(), String> {
// Seeking in streaming audio is complex - for now just update the position tracker
// Real seeking would require re-downloading from a specific byte offset
log::warn!("Seek not fully implemented for streaming audio");
Ok(())
}
/// Get current playback state
pub fn get_state(&self) -> Result<PlaybackState, String> {
Ok(PlaybackState {
is_playing: self.state.is_playing(),
position: self.state.position(),
duration: self.state.duration(),
track_id: self.state.current_track_id(),
volume: self.state.volume(),
})
}
}
/// Playback state snapshot
#[derive(Debug, Clone, serde::Serialize)]
pub struct PlaybackState {
pub is_playing: bool,
pub position: u64,
pub duration: u64,
pub track_id: u64,
pub volume: f32,
}
+4 -4
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "QBZ-NIX",
"productName": "QBZ",
"version": "0.1.0",
"identifier": "com.blitzkriegfc.qbz-nix",
"identifier": "com.blitzkriegfc.qbz",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "QBZ-NIX",
"title": "QBZ",
"width": 1280,
"height": 800,
"minWidth": 800,
@@ -38,6 +38,6 @@
],
"category": "Music",
"shortDescription": "Native Qobuz client for Linux",
"longDescription": "QBZ-NIX is a native Qobuz streaming client for Linux, designed for audiophiles who need bit-perfect playback without browser sample rate limitations."
"longDescription": "QBZ is a native Qobuz streaming client for Linux, designed for audiophiles who need bit-perfect playback without browser sample rate limitations."
}
}