fix(discord): add Apple Music cover opt-in, show Paused state

- iTunes Search API calls are now gated behind enableAppleMusicCoversDiscord
  (default: false). No external requests to Apple are made unless the user
  explicitly enables the new opt-in toggle in Settings.
- When the toggle is flipped, Discord presence is immediately re-sent for
  the current track (coversSettingChanged bypasses the early-return guard).
- When paused, activity type switches to Playing (no auto-timer) and state
  text shows "Paused" instead of the artist name.
- New authStore field + setter; new Settings toggle shown only when Discord
  RPC is enabled; i18n keys added to all 7 languages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-07 20:33:16 +02:00
parent 4b8b6ae797
commit 9ef8566d64
11 changed files with 97 additions and 29 deletions
+54 -25
View File
@@ -218,35 +218,45 @@ fn try_connect() -> Option<DiscordIpcClient> {
/// Update the Discord Rich Presence activity.
///
/// - `elapsed_secs`: seconds already played. `None` when paused — Discord shows
/// the song/artist without a running timer.
/// - `cover_art_url`: optional direct URL to album artwork. If None, tries to
/// fetch from iTunes Search API using artist + album.
/// - `is_playing`: true = playing (timer shown), false = paused (no timer, state shows "Paused").
/// - `elapsed_secs`: seconds already played. `None` when paused — no timestamp is sent so
/// Discord stops any running timer.
/// - `cover_art_url`: optional direct URL to album artwork.
/// - `fetch_itunes_covers`: if true, fetch artwork from the iTunes Search API when no
/// `cover_art_url` is provided. If false (default), fall back to the Psysonic app icon
/// without making any external request — required for privacy opt-in.
#[tauri::command]
pub async fn discord_update_presence(
state: tauri::State<'_, DiscordState>,
title: String,
artist: String,
album: Option<String>,
is_playing: bool,
elapsed_secs: Option<f64>,
cover_art_url: Option<String>,
fetch_itunes_covers: bool,
) -> Result<(), String> {
// Resolve artwork on a dedicated blocking thread — reqwest::blocking must not
// run on the Tokio async executor directly.
// Only hit the iTunes API if the user has explicitly opted in.
let artwork_url: Option<String> = if let Some(url) = cover_art_url {
Some(url)
} else if let Some(ref album_name) = album {
let http_client = state.http_client.clone();
let cache = Arc::clone(&state.artwork_cache);
let artist_c = artist.clone();
let album_c = album_name.clone();
let title_c = title.clone();
tokio::task::spawn_blocking(move || {
search_itunes_artwork(&http_client, &cache, &artist_c, &album_c, &title_c)
})
.await
.ok()
.flatten()
} else if fetch_itunes_covers {
if let Some(ref album_name) = album {
let http_client = state.http_client.clone();
let cache = Arc::clone(&state.artwork_cache);
let artist_c = artist.clone();
let album_c = album_name.clone();
let title_c = title.clone();
tokio::task::spawn_blocking(move || {
search_itunes_artwork(&http_client, &cache, &artist_c, &album_c, &title_c)
})
.await
.ok()
.flatten()
} else {
None
}
} else {
None
};
@@ -280,21 +290,40 @@ pub async fn discord_update_presence(
.large_text(large_text)
};
// When paused, show "Paused" as the state text (replaces artist name).
let state_text: String = if is_playing {
artist.clone()
} else {
"Paused".to_string()
};
// ActivityType::Listening causes the Discord client to auto-start a running
// timer from "now" even when no timestamps are provided. Switch to Playing
// when paused — Playing only shows a timer when timestamps are explicitly set.
let activity_type = if is_playing {
ActivityType::Listening
} else {
ActivityType::Playing
};
let mut activity = Activity::new()
.activity_type(ActivityType::Listening)
.activity_type(activity_type)
.details(&title)
.state(&artist)
.state(&state_text)
.assets(assets);
// Start timestamp: Discord auto-counts up from this point. We back-calculate
// it so the displayed elapsed time matches the actual playback position.
if let Some(elapsed) = elapsed_secs {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let start = now - elapsed.floor() as i64;
activity = activity.timestamps(Timestamps::new().start(start));
// Only set when playing — Playing type without timestamps shows no timer.
if is_playing {
if let Some(elapsed) = elapsed_secs {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let start = now - elapsed.floor() as i64;
activity = activity.timestamps(Timestamps::new().start(start));
}
}
if client.set_activity(activity).is_err() {