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:
joren
2026-03-24 11:19:32 +01:00
parent c8086e1f2f
commit f30e2058c1
24 changed files with 757 additions and 135 deletions

View File

@@ -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()));
}
});
}