diff --git a/src-tauri/src/discord.rs b/src-tauri/src/discord.rs index 1fef1247..61fc49d6 100644 --- a/src-tauri/src/discord.rs +++ b/src-tauri/src/discord.rs @@ -218,35 +218,45 @@ fn try_connect() -> Option { /// 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, + is_playing: bool, elapsed_secs: Option, cover_art_url: Option, + 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 = 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() { diff --git a/src/locales/de.ts b/src/locales/de.ts index 21251fcf..f837c474 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -437,6 +437,8 @@ export const deTranslation = { minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.', + discordAppleCovers: 'Cover über Apple Music für Discord laden', + discordAppleCoversDesc: 'Sendet Künstler- und Albumname an die Apple-Such-API, um Cover für dein Discord-Profil zu finden. Standardmäßig aus Datenschutzgründen deaktiviert.', nowPlayingEnabled: 'Im Livefenster anzeigen', nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.', lyricsServerFirst: 'Server-Lyrics bevorzugen', diff --git a/src/locales/en.ts b/src/locales/en.ts index 5818f14d..37f4527e 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -438,6 +438,8 @@ export const enTranslation = { minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.', + discordAppleCovers: 'Fetch covers from Apple Music for Discord', + discordAppleCoversDesc: 'Sends the artist and album name to Apple\'s search API to find cover art for your Discord profile. Disabled by default for privacy.', nowPlayingEnabled: 'Show in Now Playing', nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.', lyricsServerFirst: 'Prefer server lyrics', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 559b76f2..a596e643 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -437,6 +437,8 @@ export const frTranslation = { minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.', + discordAppleCovers: 'Récupérer les pochettes via Apple Music pour Discord', + discordAppleCoversDesc: 'Envoie le nom de l\'artiste et de l\'album à l\'API Apple pour trouver une pochette pour votre profil Discord. Désactivé par défaut pour des raisons de confidentialité.', nowPlayingEnabled: 'Afficher dans la fenêtre live', nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.', lyricsServerFirst: 'Préférer les paroles du serveur', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 3c6af2ed..a5f8cf5e 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -436,6 +436,8 @@ export const nbTranslation = { minimizeToTrayDesc: 'Når vinduet lukkes, vil Psysonic bli kjørende i oppgavelinjen fremfor å bli avsluttet.', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Vis sporet som spilles i din Discord-profil. Krever at Discord kjører.', + discordAppleCovers: 'Hent covere fra Apple Music til Discord', + discordAppleCoversDesc: 'Sender artist- og albumnavn til Apples søke-API for å finne cover til Discord-profilen din. Deaktivert som standard av personvernhensyn.', nowPlayingEnabled: 'Vis i "Nå spiller"', nowPlayingEnabledDesc: 'Send sporet som spilles av til tjenerens live-lyttervisning. Deaktiver for å stoppe sending av avspillingsdata.', lyricsServerFirst: 'Foretrekk server-sangtekst', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index d8255606..5323109b 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -437,6 +437,8 @@ export const nlTranslation = { minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.', + discordAppleCovers: 'Hoezen ophalen via Apple Music voor Discord', + discordAppleCoversDesc: 'Stuurt artiest- en albumnaam naar de Apple-zoek-API om een hoes te vinden voor je Discord-profiel. Standaard uitgeschakeld vanwege privacy.', nowPlayingEnabled: 'Weergeven in live-venster', nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.', lyricsServerFirst: 'Server-songtekst voorrang geven', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 167993f7..33c12fc9 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -456,6 +456,8 @@ export const ruTranslation = { discordRichPresence: 'Статус в Discord', discordRichPresenceDesc: 'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.', + discordAppleCovers: 'Загружать обложки через Apple Music для Discord', + discordAppleCoversDesc: 'Отправляет имя исполнителя и альбома в API поиска Apple для поиска обложки в профиле Discord. По умолчанию отключено из соображений конфиденциальности.', nowPlayingEnabled: 'Показывать в «Сейчас играет»', nowPlayingEnabledDesc: 'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 295cb22e..134ded62 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -433,6 +433,8 @@ export const zhTranslation = { minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。', + discordAppleCovers: '通过 Apple Music 为 Discord 获取封面', + discordAppleCoversDesc: '将艺术家和专辑名称发送至 Apple 搜索 API,以为 Discord 个人资料查找封面图片。出于隐私考虑,默认禁用。', nowPlayingEnabled: '在实时窗口中显示', nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。', lyricsServerFirst: '优先使用服务器歌词', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index f0d6af1b..a496a526 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -637,6 +637,21 @@ export default function Settings() { + {auth.discordRichPresence && ( + <> +
+
+
+
{t('settings.discordAppleCovers')}
+
{t('settings.discordAppleCoversDesc')}
+
+ +
+ + )}
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index e7e875e0..c820890b 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -41,6 +41,7 @@ interface AuthState { showTrayIcon: boolean; minimizeToTray: boolean; discordRichPresence: boolean; + enableAppleMusicCoversDiscord: boolean; nowPlayingEnabled: boolean; lyricsServerFirst: boolean; showFullscreenLyrics: boolean; @@ -103,6 +104,7 @@ interface AuthState { setShowTrayIcon: (v: boolean) => void; setMinimizeToTray: (v: boolean) => void; setDiscordRichPresence: (v: boolean) => void; + setEnableAppleMusicCoversDiscord: (v: boolean) => void; setNowPlayingEnabled: (v: boolean) => void; setLyricsServerFirst: (v: boolean) => void; setShowFullscreenLyrics: (v: boolean) => void; @@ -153,6 +155,7 @@ export const useAuthStore = create()( showTrayIcon: true, minimizeToTray: false, discordRichPresence: false, + enableAppleMusicCoversDiscord: false, nowPlayingEnabled: false, lyricsServerFirst: true, showFullscreenLyrics: true, @@ -236,6 +239,7 @@ export const useAuthStore = create()( setShowTrayIcon: (v) => set({ showTrayIcon: v }), setMinimizeToTray: (v) => set({ minimizeToTray: v }), setDiscordRichPresence: (v) => set({ discordRichPresence: v }), + setEnableAppleMusicCoversDiscord: (v) => set({ enableAppleMusicCoversDiscord: v }), setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }), setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }), setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }), diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index cc3eb36c..6c82fb1c 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -505,15 +505,17 @@ export function initAudioListeners(): () => void { // Discord auto-counts up the elapsed timer from the start_timestamp we set. let discordPrevTrackId: string | null = null; let discordPrevIsPlaying: boolean | null = null; + let discordPrevFetchCovers: boolean | null = null; function syncDiscord() { const { currentTrack, isPlaying, currentTime } = usePlayerStore.getState(); - const { discordRichPresence } = useAuthStore.getState(); + const { discordRichPresence, enableAppleMusicCoversDiscord } = useAuthStore.getState(); if (!discordRichPresence || !currentTrack) { if (discordPrevTrackId !== null) { discordPrevTrackId = null; discordPrevIsPlaying = null; + discordPrevFetchCovers = null; invoke('discord_clear_presence').catch(() => {}); } return; @@ -521,21 +523,25 @@ export function initAudioListeners(): () => void { const trackChanged = currentTrack.id !== discordPrevTrackId; const playingChanged = isPlaying !== discordPrevIsPlaying; - if (!trackChanged && !playingChanged) return; + const coversSettingChanged = enableAppleMusicCoversDiscord !== discordPrevFetchCovers; + if (!trackChanged && !playingChanged && !coversSettingChanged) return; discordPrevTrackId = currentTrack.id; discordPrevIsPlaying = isPlaying; + discordPrevFetchCovers = enableAppleMusicCoversDiscord; invoke('discord_update_presence', { title: currentTrack.title, artist: currentTrack.artist ?? 'Unknown Artist', album: currentTrack.album ?? null, + isPlaying, // Pass elapsed when playing so Discord shows a live running timer. - // Pass null when paused — Discord shows the song/artist without a timer. + // Pass null when paused — Discord clears the timer. elapsedSecs: isPlaying ? currentTime : null, // coverArtUrl is intentionally not passed — Subsonic URLs require auth. - // Backend will fetch artwork from iTunes Search API instead. + // iTunes cover fetching is only done when explicitly opted in. coverArtUrl: null, + fetchItunesCovers: enableAppleMusicCoversDiscord, }).catch(() => {}); }