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:
@@ -62,6 +62,7 @@ void qobuz_backend_pause(QobuzBackendOpaque *backend);
|
||||
void qobuz_backend_resume(QobuzBackendOpaque *backend);
|
||||
void qobuz_backend_stop(QobuzBackendOpaque *backend);
|
||||
void qobuz_backend_set_volume(QobuzBackendOpaque *backend, uint8_t volume);
|
||||
void qobuz_backend_seek(QobuzBackendOpaque *backend, uint64_t position_secs);
|
||||
uint64_t qobuz_backend_get_position(const QobuzBackendOpaque *backend);
|
||||
uint64_t qobuz_backend_get_duration(const QobuzBackendOpaque *backend);
|
||||
uint8_t qobuz_backend_get_volume(const QobuzBackendOpaque *backend);
|
||||
|
||||
@@ -402,6 +402,11 @@ pub unsafe extern "C" fn qobuz_backend_set_volume(ptr: *mut Backend, volume: u8)
|
||||
(*ptr).0.player.set_volume(volume);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qobuz_backend_seek(ptr: *mut Backend, position_secs: u64) {
|
||||
(*ptr).0.player.seek(position_secs);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qobuz_backend_get_position(ptr: *const Backend) -> u64 {
|
||||
(*ptr).0.player.status.get_position()
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user