feat: playlist management, gapless playback, ReplayGain, Qobuz theme
Playlist management: - Add/remove tracks from playlists via right-click context menu - Create new playlists (right-click Playlists sidebar header) - Delete playlists with confirmation dialog (right-click playlist item) - Playlist view removes track immediately on delete (optimistic) - Deleting currently-open playlist clears the track view Gapless playback: - Single long-running audio thread owns AudioOutput; CPAL stream stays open between tracks eliminating device teardown/startup gap - Decode runs inline on the audio thread; command channel polled via try_recv() so Pause/Resume/Seek/Stop/Play all work without spawning - New Play command arriving mid-decode is handled immediately, reusing the same audio output for zero-gap transition - Position timer reduced from 500 ms to 50 ms for faster track-end detection - URL/metadata prefetch: when gapless is enabled Qt pre-fetches the next track while the current one is still playing ReplayGain: - Toggled in Settings → Playback - replaygain_track_gain (dB) from track audio_info converted to linear gain factor and applied per-sample alongside volume Qobuz dark theme: - Background #191919, base #141414, accent #FFB232 (yellow-orange) - Selection highlight, slider fill, scrollbar hover all use #FFB232 - Links use Qobuz blue #46B3EE - Hi-res H badges updated to #FFB232 (from #FFD700) - Now-playing row uses #FFB232 (was Spotify green) - QSS stylesheet for scrollbars, menus, inputs, buttons, groups Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
161
rust/src/lib.rs
161
rust/src/lib.rs
@@ -75,12 +75,20 @@ pub type EventCallback = unsafe extern "C" fn(*mut c_void, c_int, *const c_char)
|
||||
|
||||
// ---------- Backend ----------
|
||||
|
||||
struct PrefetchedTrack {
|
||||
track_id: i64,
|
||||
track: api::models::TrackDto,
|
||||
url: String,
|
||||
}
|
||||
|
||||
struct BackendInner {
|
||||
client: Arc<Mutex<QobuzClient>>,
|
||||
player: Player,
|
||||
rt: Runtime,
|
||||
cb: EventCallback,
|
||||
ud: SendPtr,
|
||||
replaygain_enabled: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
prefetch: std::sync::Arc<tokio::sync::Mutex<Option<PrefetchedTrack>>>,
|
||||
}
|
||||
|
||||
pub struct Backend(BackendInner);
|
||||
@@ -121,6 +129,8 @@ pub unsafe extern "C" fn qobuz_backend_new(
|
||||
rt,
|
||||
cb: event_cb,
|
||||
ud: SendPtr(userdata),
|
||||
replaygain_enabled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
prefetch: std::sync::Arc::new(tokio::sync::Mutex::new(None)),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -341,35 +351,58 @@ pub unsafe extern "C" fn qobuz_backend_play_track(
|
||||
let format = Format::from_id(format_id);
|
||||
let cmd_tx = inner.player.cmd_tx.clone();
|
||||
let status = inner.player.status.clone();
|
||||
let prefetch = inner.prefetch.clone();
|
||||
let rg_enabled = inner.replaygain_enabled.clone();
|
||||
|
||||
spawn(inner, async move {
|
||||
// 1. Track metadata
|
||||
let track = match client.lock().await.get_track(track_id).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; }
|
||||
// 1. Check prefetch cache first for zero-gap start
|
||||
let cached = {
|
||||
let mut lock = prefetch.lock().await;
|
||||
if lock.as_ref().map(|p| p.track_id == track_id).unwrap_or(false) {
|
||||
lock.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Stream URL
|
||||
let url_dto = match client.lock().await.get_track_url(track_id, format).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; }
|
||||
};
|
||||
let url = match url_dto.url {
|
||||
Some(u) => u,
|
||||
None => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json("no stream URL")); return; }
|
||||
let (track, url) = if let Some(pf) = cached {
|
||||
(pf.track, pf.url)
|
||||
} else {
|
||||
// Fetch track metadata
|
||||
let track = match client.lock().await.get_track(track_id).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; }
|
||||
};
|
||||
// Fetch stream URL
|
||||
let url_dto = match client.lock().await.get_track_url(track_id, format).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; }
|
||||
};
|
||||
let url = match url_dto.url {
|
||||
Some(u) => u,
|
||||
None => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json("no stream URL")); return; }
|
||||
};
|
||||
(track, url)
|
||||
};
|
||||
|
||||
// 3. Notify track change
|
||||
// 2. Notify track change
|
||||
if let Ok(j) = serde_json::to_string(&track) {
|
||||
call_cb(cb, ud, EV_TRACK_CHANGED, &j);
|
||||
}
|
||||
|
||||
// 3. Compute ReplayGain if enabled
|
||||
let replaygain_db = if rg_enabled.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
track.audio_info.as_ref().and_then(|ai| ai.replaygain_track_gain)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 4. Update status + send play command
|
||||
*status.current_track.lock().unwrap() = Some(track.clone());
|
||||
if let Some(dur) = track.duration {
|
||||
status.duration_secs.store(dur as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
let _ = cmd_tx.send(player::PlayerCommand::Play(player::TrackInfo { track, url, format }));
|
||||
let _ = cmd_tx.send(player::PlayerCommand::Play(player::TrackInfo { track, url, format, replaygain_db }));
|
||||
|
||||
// 5. State notification
|
||||
call_cb(cb, ud, EV_STATE_CHANGED, r#"{"state":"playing"}"#);
|
||||
@@ -438,6 +471,41 @@ pub unsafe extern "C" fn qobuz_backend_take_track_finished(ptr: *mut Backend) ->
|
||||
if finished { 1 } else { 0 }
|
||||
}
|
||||
|
||||
// ---------- ReplayGain / Gapless ----------
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qobuz_backend_set_replaygain(ptr: *mut Backend, enabled: bool) {
|
||||
(*ptr).0.replaygain_enabled.store(enabled, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qobuz_backend_prefetch_track(
|
||||
ptr: *mut Backend,
|
||||
track_id: i64,
|
||||
format_id: i32,
|
||||
) {
|
||||
let inner = &(*ptr).0;
|
||||
let client = inner.client.clone();
|
||||
let prefetch = inner.prefetch.clone();
|
||||
let format = Format::from_id(format_id);
|
||||
|
||||
spawn(inner, async move {
|
||||
let track = match client.lock().await.get_track(track_id).await {
|
||||
Ok(t) => t,
|
||||
Err(_) => return,
|
||||
};
|
||||
let url_dto = match client.lock().await.get_track_url(track_id, format).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => return,
|
||||
};
|
||||
let url = match url_dto.url {
|
||||
Some(u) => u,
|
||||
None => return,
|
||||
};
|
||||
*prefetch.lock().await = Some(PrefetchedTrack { track_id, track, url });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Favorites modification ----------
|
||||
|
||||
#[no_mangle]
|
||||
@@ -489,3 +557,68 @@ pub unsafe extern "C" fn qobuz_backend_remove_fav_album(ptr: *mut Backend, album
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Playlist management ----------
|
||||
|
||||
pub const EV_PLAYLIST_CREATED: c_int = 20;
|
||||
pub const EV_PLAYLIST_DELETED: c_int = 21;
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qobuz_backend_create_playlist(ptr: *mut Backend, name: *const c_char) {
|
||||
let inner = &(*ptr).0;
|
||||
let name = CStr::from_ptr(name).to_string_lossy().into_owned();
|
||||
let client = inner.client.clone();
|
||||
let cb = inner.cb; let ud = inner.ud;
|
||||
spawn(inner, async move {
|
||||
match client.lock().await.create_playlist(&name).await {
|
||||
Ok(p) => call_cb(cb, ud, EV_PLAYLIST_CREATED, &serde_json::to_string(&p).unwrap_or_default()),
|
||||
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qobuz_backend_delete_playlist(ptr: *mut Backend, playlist_id: i64) {
|
||||
let inner = &(*ptr).0;
|
||||
let client = inner.client.clone();
|
||||
let cb = inner.cb; let ud = inner.ud;
|
||||
spawn(inner, async move {
|
||||
match client.lock().await.delete_playlist(playlist_id).await {
|
||||
Ok(()) => call_cb(cb, ud, EV_PLAYLIST_DELETED,
|
||||
&serde_json::json!({"playlist_id": playlist_id}).to_string()),
|
||||
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qobuz_backend_add_track_to_playlist(
|
||||
ptr: *mut Backend,
|
||||
playlist_id: i64,
|
||||
track_id: i64,
|
||||
) {
|
||||
let inner = &(*ptr).0;
|
||||
let client = inner.client.clone();
|
||||
let cb = inner.cb; let ud = inner.ud;
|
||||
spawn(inner, async move {
|
||||
if let Err(e) = client.lock().await.add_track_to_playlist(playlist_id, track_id).await {
|
||||
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qobuz_backend_delete_track_from_playlist(
|
||||
ptr: *mut Backend,
|
||||
playlist_id: i64,
|
||||
playlist_track_id: i64,
|
||||
) {
|
||||
let inner = &(*ptr).0;
|
||||
let client = inner.client.clone();
|
||||
let cb = inner.cb; let ud = inner.ud;
|
||||
spawn(inner, async move {
|
||||
if let Err(e) = client.lock().await.delete_track_from_playlist(playlist_id, playlist_track_id).await {
|
||||
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user