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:
207
rust/src/player/decoder.rs
Normal file
207
rust/src/player/decoder.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
use anyhow::Result;
|
||||
use std::io::{self, Read, Seek, SeekFrom};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
use symphonia::core::{
|
||||
codecs::DecoderOptions,
|
||||
errors::Error as SymphoniaError,
|
||||
formats::FormatOptions,
|
||||
io::{MediaSource, MediaSourceStream},
|
||||
meta::MetadataOptions,
|
||||
probe::Hint,
|
||||
};
|
||||
|
||||
use crate::player::{output::AudioOutput, PlayerStatus};
|
||||
|
||||
/// First 512 KiB of stream kept in memory to support backward seeks during probing.
|
||||
const HEAD_SIZE: usize = 512 * 1024;
|
||||
|
||||
struct HttpStreamSource {
|
||||
reader: reqwest::blocking::Response,
|
||||
head: Vec<u8>,
|
||||
reader_pos: u64,
|
||||
pos: u64,
|
||||
content_length: Option<u64>,
|
||||
}
|
||||
|
||||
impl HttpStreamSource {
|
||||
fn new(response: reqwest::blocking::Response, content_length: Option<u64>) -> Self {
|
||||
Self {
|
||||
reader: response,
|
||||
head: Vec::new(),
|
||||
reader_pos: 0,
|
||||
pos: 0,
|
||||
content_length,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for HttpStreamSource {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let pos = self.pos as usize;
|
||||
if pos < self.head.len() {
|
||||
let avail = self.head.len() - pos;
|
||||
let n = buf.len().min(avail);
|
||||
buf[..n].copy_from_slice(&self.head[pos..pos + n]);
|
||||
self.pos += n as u64;
|
||||
return Ok(n);
|
||||
}
|
||||
|
||||
let n = self.reader.read(buf)?;
|
||||
if n > 0 {
|
||||
if self.reader_pos < HEAD_SIZE as u64 {
|
||||
let capacity = HEAD_SIZE.saturating_sub(self.head.len());
|
||||
let to_buf = n.min(capacity);
|
||||
if to_buf > 0 {
|
||||
self.head.extend_from_slice(&buf[..to_buf]);
|
||||
}
|
||||
}
|
||||
self.reader_pos += n as u64;
|
||||
self.pos += n as u64;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for HttpStreamSource {
|
||||
fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
|
||||
let cl = self.content_length.unwrap_or(u64::MAX);
|
||||
let target: u64 = match from {
|
||||
SeekFrom::Start(n) => n,
|
||||
SeekFrom::End(n) if n < 0 => cl.saturating_sub((-n) as u64),
|
||||
SeekFrom::End(_) => cl,
|
||||
SeekFrom::Current(n) if n >= 0 => self.pos.saturating_add(n as u64),
|
||||
SeekFrom::Current(n) => self.pos.saturating_sub((-n) as u64),
|
||||
};
|
||||
|
||||
if target == self.pos {
|
||||
return Ok(self.pos);
|
||||
}
|
||||
|
||||
if target < self.reader_pos {
|
||||
if target < self.head.len() as u64 {
|
||||
self.pos = target;
|
||||
return Ok(self.pos);
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"backward seek past head buffer",
|
||||
));
|
||||
}
|
||||
|
||||
let mut remaining = target - self.reader_pos;
|
||||
while remaining > 0 {
|
||||
let mut discard = [0u8; 8192];
|
||||
let want = (remaining as usize).min(discard.len());
|
||||
match self.reader.read(&mut discard[..want]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
if self.reader_pos < HEAD_SIZE as u64 {
|
||||
let capacity = HEAD_SIZE.saturating_sub(self.head.len());
|
||||
let to_buf = n.min(capacity);
|
||||
if to_buf > 0 {
|
||||
self.head.extend_from_slice(&discard[..to_buf]);
|
||||
}
|
||||
}
|
||||
self.reader_pos += n as u64;
|
||||
remaining -= n as u64;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
self.pos = self.reader_pos;
|
||||
Ok(self.pos)
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for HttpStreamSource {
|
||||
fn is_seekable(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn byte_len(&self) -> Option<u64> {
|
||||
self.content_length
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream and decode audio from `url`. Runs on a dedicated OS thread.
|
||||
pub fn play_track(
|
||||
url: &str,
|
||||
status: &PlayerStatus,
|
||||
stop: &Arc<AtomicBool>,
|
||||
paused: &Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
let response = reqwest::blocking::get(url)?;
|
||||
let content_length = response.content_length();
|
||||
let source = HttpStreamSource::new(response, content_length);
|
||||
let mss = MediaSourceStream::new(Box::new(source), Default::default());
|
||||
|
||||
let hint = Hint::new();
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
|
||||
.map_err(|e| anyhow::anyhow!("probe failed: {e}"))?;
|
||||
|
||||
let mut format = probed.format;
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| anyhow::anyhow!("no audio tracks"))?
|
||||
.clone();
|
||||
|
||||
let track_id = track.id;
|
||||
let sample_rate = track.codec_params.sample_rate.unwrap_or(44100);
|
||||
let channels = track.codec_params.channels.map(|c| c.count()).unwrap_or(2);
|
||||
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| anyhow::anyhow!("decoder init failed: {e}"))?;
|
||||
|
||||
let mut audio_output = AudioOutput::try_open(sample_rate, channels)?;
|
||||
|
||||
loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
while paused.load(Ordering::SeqCst) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let packet = match format.next_packet() {
|
||||
Ok(p) => p,
|
||||
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
break;
|
||||
}
|
||||
Err(SymphoniaError::ResetRequired) => {
|
||||
decoder.reset();
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(anyhow::anyhow!("format error: {e}")),
|
||||
};
|
||||
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(ts) = packet.ts().checked_div(sample_rate as u64) {
|
||||
status.position_secs.store(ts, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
match decoder.decode(&packet) {
|
||||
Ok(decoded) => {
|
||||
let volume = status.volume.load(Ordering::Relaxed) as f32 / 100.0;
|
||||
audio_output.write(decoded, volume, stop)?;
|
||||
}
|
||||
Err(SymphoniaError::IoError(_)) => break,
|
||||
Err(SymphoniaError::DecodeError(e)) => eprintln!("decode error: {e}"),
|
||||
Err(e) => return Err(anyhow::anyhow!("decode error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
186
rust/src/player/mod.rs
Normal file
186
rust/src/player/mod.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
81
rust/src/player/output.rs
Normal file
81
rust/src/player/output.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use cpal::{
|
||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||
StreamConfig,
|
||||
};
|
||||
use rb::{RbConsumer, RbProducer, SpscRb, RB};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use symphonia::core::audio::AudioBufferRef;
|
||||
|
||||
const RING_BUFFER_SIZE: usize = 32 * 1024;
|
||||
|
||||
pub struct AudioOutput {
|
||||
ring_buf_producer: rb::Producer<f32>,
|
||||
_stream: cpal::Stream,
|
||||
}
|
||||
|
||||
impl AudioOutput {
|
||||
pub fn try_open(sample_rate: u32, channels: usize) -> Result<Self> {
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or_else(|| anyhow!("no output device"))?;
|
||||
|
||||
let config = StreamConfig {
|
||||
channels: channels as u16,
|
||||
sample_rate: cpal::SampleRate(sample_rate),
|
||||
buffer_size: cpal::BufferSize::Default,
|
||||
};
|
||||
|
||||
let ring = SpscRb::new(RING_BUFFER_SIZE);
|
||||
let (producer, consumer) = (ring.producer(), ring.consumer());
|
||||
|
||||
let stream = device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [f32], _| {
|
||||
let n = consumer.read(data).unwrap_or(0);
|
||||
for s in &mut data[n..] {
|
||||
*s = 0.0;
|
||||
}
|
||||
},
|
||||
|e| eprintln!("audio stream error: {e}"),
|
||||
None,
|
||||
)?;
|
||||
|
||||
stream.play()?;
|
||||
|
||||
Ok(Self {
|
||||
ring_buf_producer: producer,
|
||||
_stream: stream,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write(
|
||||
&mut self,
|
||||
decoded: AudioBufferRef<'_>,
|
||||
volume: f32,
|
||||
stop: &Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
let mut sample_buf = symphonia::core::audio::SampleBuffer::<f32>::new(
|
||||
decoded.capacity() as u64,
|
||||
*decoded.spec(),
|
||||
);
|
||||
sample_buf.copy_interleaved_ref(decoded);
|
||||
let samples: Vec<f32> = sample_buf.samples().iter().map(|s| s * volume).collect();
|
||||
|
||||
let mut remaining = &samples[..];
|
||||
while !remaining.is_empty() {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
}
|
||||
match self.ring_buf_producer.write_blocking(remaining) {
|
||||
Some(n) => remaining = &remaining[n..],
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user