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

@@ -89,6 +89,15 @@ impl QobuzClient {
Ok(body)
}
fn post_request(&self, method: &str) -> reqwest::RequestBuilder {
let mut builder = self.http.post(self.url(method));
builder = builder.query(&[("app_id", self.app_id.as_str())]);
if let Some(token) = &self.auth_token {
builder = builder.header("Authorization", format!("Bearer {}", token));
}
builder
}
fn get_request(&self, method: &str) -> reqwest::RequestBuilder {
let mut builder = self.http.get(self.url(method));
builder = builder.query(&[("app_id", self.app_id.as_str())]);
@@ -329,6 +338,55 @@ impl QobuzClient {
Ok(serde_json::from_value(body["artists"].clone())?)
}
// --- Playlist management ---
pub async fn create_playlist(&self, name: &str) -> Result<PlaylistDto> {
let resp = self
.post_request("playlist/create")
.form(&[("name", name), ("is_public", "false"), ("is_collaborative", "false")])
.send()
.await?;
let body = Self::check_response(resp).await?;
Ok(serde_json::from_value(body)?)
}
pub async fn add_track_to_playlist(&self, playlist_id: i64, track_id: i64) -> Result<()> {
let resp = self
.post_request("playlist/addTracks")
.form(&[
("playlist_id", playlist_id.to_string()),
("track_ids", track_id.to_string()),
("no_duplicate", "true".to_string()),
])
.send()
.await?;
Self::check_response(resp).await?;
Ok(())
}
pub async fn delete_playlist(&self, playlist_id: i64) -> Result<()> {
let resp = self
.get_request("playlist/delete")
.query(&[("playlist_id", &playlist_id.to_string())])
.send()
.await?;
Self::check_response(resp).await?;
Ok(())
}
pub async fn delete_track_from_playlist(&self, playlist_id: i64, playlist_track_id: i64) -> Result<()> {
let resp = self
.post_request("playlist/deleteTracks")
.form(&[
("playlist_id", playlist_id.to_string()),
("playlist_track_ids", playlist_track_id.to_string()),
])
.send()
.await?;
Self::check_response(resp).await?;
Ok(())
}
pub async fn add_fav_track(&self, track_id: i64) -> Result<()> {
let resp = self
.get_request("favorite/create")

View File

@@ -48,6 +48,7 @@ pub struct TrackDto {
pub title: Option<String>,
pub duration: Option<i64>,
pub track_number: Option<i32>,
pub playlist_track_id: Option<i64>,
pub album: Option<AlbumDto>,
pub performer: Option<ArtistDto>,
pub composer: Option<ArtistDto>,