Initial implementation of qobuz-qt

- Rust backend (qobuz-backend static lib): Qobuz API client (reqwest/tokio),
  Symphonia audio decoder, CPAL audio output, extern "C" FFI bridge
- Qt 6 frontend mirroring spotify-qt layout: toolbar with playback controls,
  left library dock, central track list, right search panel
- Auth: email/password login with MD5-signed requests; session token persisted
  via QSettings
- Playback: double-click a track → Rust fetches stream URL → Symphonia decodes
  → CPAL outputs to default audio device
- Dark Fusion palette matching spotify-qt feel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-23 23:34:23 +01:00
commit 9402dca7ed
40 changed files with 3963 additions and 0 deletions

186
rust/src/player/mod.rs Normal file
View File

@@ -0,0 +1,186 @@
mod decoder;
pub mod output;
use std::sync::{
atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering},
Arc,
};
use std::time::Duration;
use crate::api::{Format, TrackDto};
#[derive(Debug, Clone)]
pub enum PlayerCommand {
Play(TrackInfo),
Pause,
Resume,
Stop,
Seek(u64),
SetVolume(u8),
}
#[derive(Debug, Clone)]
pub struct TrackInfo {
pub track: TrackDto,
pub url: String,
pub format: Format,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PlayerState {
Idle,
Playing,
Paused,
Stopped,
Error(String),
}
#[derive(Clone)]
pub struct PlayerStatus {
pub state: Arc<std::sync::Mutex<PlayerState>>,
pub position_secs: Arc<AtomicU64>,
pub duration_secs: Arc<AtomicU64>,
pub volume: Arc<AtomicU8>,
pub current_track: Arc<std::sync::Mutex<Option<TrackDto>>>,
/// Set to true by the decode thread when a track finishes naturally.
pub track_finished: Arc<AtomicBool>,
}
impl PlayerStatus {
pub fn new() -> Self {
Self {
state: Arc::new(std::sync::Mutex::new(PlayerState::Idle)),
position_secs: Arc::new(AtomicU64::new(0)),
duration_secs: Arc::new(AtomicU64::new(0)),
volume: Arc::new(AtomicU8::new(80)),
current_track: Arc::new(std::sync::Mutex::new(None)),
track_finished: Arc::new(AtomicBool::new(false)),
}
}
pub fn get_state(&self) -> PlayerState {
self.state.lock().unwrap().clone()
}
pub fn get_position(&self) -> u64 {
self.position_secs.load(Ordering::Relaxed)
}
pub fn get_duration(&self) -> u64 {
self.duration_secs.load(Ordering::Relaxed)
}
pub fn get_volume(&self) -> u8 {
self.volume.load(Ordering::Relaxed)
}
pub fn get_current_track(&self) -> Option<TrackDto> {
self.current_track.lock().unwrap().clone()
}
}
pub struct Player {
pub cmd_tx: std::sync::mpsc::SyncSender<PlayerCommand>,
pub status: PlayerStatus,
}
impl Player {
pub fn new() -> Self {
let (cmd_tx, cmd_rx) = std::sync::mpsc::sync_channel(32);
let status = PlayerStatus::new();
let status_clone = status.clone();
std::thread::spawn(move || {
player_loop(cmd_rx, status_clone);
});
Self { cmd_tx, status }
}
pub fn send(&self, cmd: PlayerCommand) {
self.cmd_tx.send(cmd).ok();
}
pub fn play_track(&self, track: TrackDto, url: String, format: Format) {
self.send(PlayerCommand::Play(TrackInfo { track, url, format }));
}
pub fn pause(&self) {
self.send(PlayerCommand::Pause);
}
pub fn resume(&self) {
self.send(PlayerCommand::Resume);
}
pub fn stop(&self) {
self.send(PlayerCommand::Stop);
}
pub fn set_volume(&self, vol: u8) {
self.status.volume.store(vol, Ordering::Relaxed);
self.send(PlayerCommand::SetVolume(vol));
}
}
fn player_loop(rx: std::sync::mpsc::Receiver<PlayerCommand>, status: PlayerStatus) {
let mut stop_flag = Arc::new(AtomicBool::new(true));
let paused = Arc::new(AtomicBool::new(false));
loop {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(cmd) => match cmd {
PlayerCommand::Play(info) => {
stop_flag.store(true, Ordering::SeqCst);
stop_flag = Arc::new(AtomicBool::new(false));
paused.store(false, Ordering::SeqCst);
*status.state.lock().unwrap() = PlayerState::Playing;
*status.current_track.lock().unwrap() = Some(info.track.clone());
if let Some(dur) = info.track.duration {
status.duration_secs.store(dur as u64, Ordering::Relaxed);
}
status.position_secs.store(0, Ordering::Relaxed);
let status_c = status.clone();
let stop_c = stop_flag.clone();
let paused_c = paused.clone();
std::thread::spawn(move || {
match decoder::play_track(&info.url, &status_c, &stop_c, &paused_c) {
Ok(()) => {
if !stop_c.load(Ordering::SeqCst) {
*status_c.state.lock().unwrap() = PlayerState::Idle;
status_c.track_finished.store(true, Ordering::SeqCst);
}
}
Err(e) => {
eprintln!("playback error: {e}");
*status_c.state.lock().unwrap() =
PlayerState::Error(e.to_string());
}
}
});
}
PlayerCommand::Pause => {
paused.store(true, Ordering::SeqCst);
*status.state.lock().unwrap() = PlayerState::Paused;
}
PlayerCommand::Resume => {
paused.store(false, Ordering::SeqCst);
*status.state.lock().unwrap() = PlayerState::Playing;
}
PlayerCommand::Stop => {
stop_flag.store(true, Ordering::SeqCst);
*status.state.lock().unwrap() = PlayerState::Idle;
*status.current_track.lock().unwrap() = None;
status.position_secs.store(0, Ordering::Relaxed);
status.duration_secs.store(0, Ordering::Relaxed);
}
PlayerCommand::SetVolume(_) | PlayerCommand::Seek(_) => {}
},
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
}