feat: seeking support and Last.fm scrobbling

Seeking:
- Rust player: seek_requested/seek_target_secs atomics on PlayerStatus
- Decoder loop checks for seek each iteration, calls format.seek() and resets decoder
- New qobuz_backend_seek C FFI + QobuzBackend::seek(quint64)
- Progress slider onProgressReleased now seeks to the dragged position

Last.fm:
- LastFmScrobbler: now-playing + scrobble (50% or 240s threshold, min 30s)
- API signature follows Last.fm spec (sorted params, md5)
- Settings dialog: API key/secret, username/password, Connect button with status
- AppSettings: lastfm/enabled, api_key, api_secret, session_key
- Scrobbler wired to trackChanged, positionChanged, trackFinished in MainWindow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-24 01:13:06 +01:00
parent ecaee4d907
commit b9b47f80e7
14 changed files with 357 additions and 10 deletions

View File

@@ -8,10 +8,11 @@ use std::sync::{
use symphonia::core::{
codecs::DecoderOptions,
errors::Error as SymphoniaError,
formats::FormatOptions,
formats::{FormatOptions, SeekMode, SeekTo},
io::{MediaSource, MediaSourceStream},
meta::MetadataOptions,
probe::Hint,
units::Time,
};
use crate::player::{output::AudioOutput, PlayerStatus};
@@ -172,6 +173,21 @@ pub fn play_track(
}
}
if status.seek_requested.load(Ordering::SeqCst) {
status.seek_requested.store(false, Ordering::SeqCst);
let target = status.seek_target_secs.load(Ordering::Relaxed);
let seeked = format.seek(
SeekMode::Coarse,
SeekTo::Time { time: Time::from(target), track_id: None },
);
if let Ok(s) = seeked {
let actual = s.actual_ts / sample_rate as u64;
status.position_secs.store(actual, Ordering::Relaxed);
}
decoder.reset();
continue;
}
let packet = match format.next_packet() {
Ok(p) => p,
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {

View File

@@ -44,6 +44,9 @@ pub struct PlayerStatus {
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>,
/// Set by the player loop when a seek command arrives; cleared by the decode thread.
pub seek_requested: Arc<AtomicBool>,
pub seek_target_secs: Arc<AtomicU64>,
}
impl PlayerStatus {
@@ -55,6 +58,8 @@ impl PlayerStatus {
volume: Arc::new(AtomicU8::new(80)),
current_track: Arc::new(std::sync::Mutex::new(None)),
track_finished: Arc::new(AtomicBool::new(false)),
seek_requested: Arc::new(AtomicBool::new(false)),
seek_target_secs: Arc::new(AtomicU64::new(0)),
}
}
@@ -121,6 +126,11 @@ impl Player {
self.status.volume.store(vol, Ordering::Relaxed);
self.send(PlayerCommand::SetVolume(vol));
}
pub fn seek(&self, secs: u64) {
self.status.seek_target_secs.store(secs, Ordering::Relaxed);
self.status.seek_requested.store(true, Ordering::SeqCst);
}
}
fn player_loop(rx: std::sync::mpsc::Receiver<PlayerCommand>, status: PlayerStatus) {
@@ -177,7 +187,11 @@ fn player_loop(rx: std::sync::mpsc::Receiver<PlayerCommand>, status: PlayerStatu
status.position_secs.store(0, Ordering::Relaxed);
status.duration_secs.store(0, Ordering::Relaxed);
}
PlayerCommand::SetVolume(_) | PlayerCommand::Seek(_) => {}
PlayerCommand::SetVolume(_) => {}
PlayerCommand::Seek(secs) => {
status.seek_target_secs.store(secs, Ordering::Relaxed);
status.seek_requested.store(true, Ordering::SeqCst);
}
},
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,