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:
1 parent
cdfd3263de
commit
3a0e3f26d5
22 files changed
+1950
-298
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user