diff --git a/src-tauri/src/discord.rs b/src-tauri/src/discord.rs index 61fc49d6..590c68b9 100644 --- a/src-tauri/src/discord.rs +++ b/src-tauri/src/discord.rs @@ -216,6 +216,16 @@ fn try_connect() -> Option { Some(client) } +/// Apply a template string, replacing placeholders with actual values. +/// Supported placeholders: {title}, {artist}, {album} +fn apply_template(template: &str, title: &str, artist: &str, album: Option<&str>) -> String { + let album_text = album.unwrap_or(""); + template + .replace("{title}", title) + .replace("{artist}", artist) + .replace("{album}", album_text) +} + /// Update the Discord Rich Presence activity. /// /// - `is_playing`: true = playing (timer shown), false = paused (no timer, state shows "Paused"). @@ -225,6 +235,12 @@ fn try_connect() -> Option { /// - `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. +/// - `details_template`: template string for the "details" field. Default: "{artist} - {title}". +/// Supported placeholders: {title}, {artist}, {album} +/// - `state_template`: template string for the "state" field. Default: "{album}". +/// Supported placeholders: {title}, {artist}, {album} +/// - `large_text_template`: template string for the large image tooltip. Default: "{album}". +/// Supported placeholders: {title}, {artist}, {album} #[tauri::command] pub async fn discord_update_presence( state: tauri::State<'_, DiscordState>, @@ -235,6 +251,9 @@ pub async fn discord_update_presence( elapsed_secs: Option, cover_art_url: Option, fetch_itunes_covers: bool, + details_template: Option, + state_template: Option, + large_text_template: Option, ) -> Result<(), String> { // Resolve artwork on a dedicated blocking thread — reqwest::blocking must not // run on the Tokio async executor directly. @@ -273,62 +292,54 @@ pub async fn discord_update_presence( let client = guard.as_mut().unwrap(); - // Discord RPC only exposes two visible text rows (details + state). - // The application name "Psysonic" is shown automatically by Discord as the - // header line. Album goes into large_text — visible as a hover tooltip on - // the cover art icon. - let large_text = album.as_deref().unwrap_or("Psysonic"); + // Apply templates for the three configurable text fields. + let details_str = details_template.as_deref().unwrap_or("{artist} - {title}"); + let details_text = apply_template(details_str, &title, &artist, album.as_deref()); + + let state_str = state_template.as_deref().unwrap_or("{album}"); + let state_text = apply_template(state_str, &title, &artist, album.as_deref()); + + let large_text_str = large_text_template.as_deref().unwrap_or("{album}"); + let large_text = apply_template(large_text_str, &title, &artist, album.as_deref()); let assets = if let Some(ref url) = artwork_url { Assets::new() .large_image(url.as_str()) - .large_text(large_text) + .large_text(&large_text) } else { // Fallback to default Psysonic icon Assets::new() .large_image("psysonic") - .large_text(large_text) + .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() - }; + // When paused: clear activity completely to avoid any timer issues + // When playing: show full activity with timer + if !is_playing { + if client.clear_activity().is_err() { + *guard = None; + } + return Ok(()); + } - // 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(activity_type) - .details(&title) + // Only reach here when playing + let activity = Activity::new() + .activity_type(ActivityType::Listening) + .details(&details_text) .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. - // Only set when playing — Playing type without timestamps shows no timer. - if is_playing { - if let Some(elapsed) = elapsed_secs { + .assets(assets) + .timestamps(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)); - } - } + Timestamps::new().start(start) + } else { + Timestamps::new() + }); if client.set_activity(activity).is_err() { - // IPC pipe broke (Discord restarted etc.) — drop the client so the next - // call re-connects. *guard = None; } diff --git a/src/locales/de.ts b/src/locales/de.ts index 9752cc7e..ee7e855c 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -539,6 +539,11 @@ export const deTranslation = { useCustomTitlebarDesc: 'Ersetzt die System-Titelleiste durch eine eingebaute, die zum App-Theme passt. Deaktivieren, um die native GNOME/GTK-Titelleiste zu verwenden.', 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.', + discordTemplates: 'Benutzerdefinierte Textvorlagen', + discordTemplatesDesc: 'Passen Sie an, welche Informationen in Ihrem Discord-Profil angezeigt werden. Variablen: {title}, {artist}, {album}', + discordTemplateDetails: 'Primäre Zeile (details)', + discordTemplateState: 'Sekundäre Zeile (state)', + discordTemplateLargeText: 'Album-Tooltip (largeText)', 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 62bd57e2..968dccd7 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -541,6 +541,11 @@ export const enTranslation = { useCustomTitlebarDesc: 'Replace the system title bar with a built-in one that matches the app theme. Disable to use the native GNOME/GTK title bar.', 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.', + discordTemplates: 'Custom text templates', + discordTemplatesDesc: 'Customize what information is shown on your Discord profile. Variables: {title}, {artist}, {album}', + discordTemplateDetails: 'Primary line (details)', + discordTemplateState: 'Secondary line (state)', + discordTemplateLargeText: 'Album tooltip (largeText)', 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/es.ts b/src/locales/es.ts index ad1e2e40..febb771d 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -542,6 +542,11 @@ export const esTranslation = { useCustomTitlebarDesc: 'Reemplaza la barra de título del sistema con una integrada que coincide con el tema de la app. Desactiva para usar la barra nativa de GNOME/GTK.', discordAppleCovers: 'Obtener portadas de Apple Music para Discord', discordAppleCoversDesc: 'Envía el artista y nombre del álbum a la API de búsqueda de Apple para encontrar portadas para tu perfil de Discord. Desactivado por defecto por privacidad.', + discordTemplates: 'Plantillas de texto personalizadas', + discordTemplatesDesc: 'Personaliza qué información se muestra en tu perfil de Discord. Variables: {title}, {artist}, {album}', + discordTemplateDetails: 'Línea principal (details)', + discordTemplateState: 'Línea secundaria (state)', + discordTemplateLargeText: 'Tooltip del álbum (largeText)', nowPlayingEnabled: 'Mostrar en Reproduciendo Ahora', nowPlayingEnabledDesc: 'Transmite tu pista actual a la vista de oyentes en vivo del servidor. Desactiva para dejar de enviar datos de reproducción.', lyricsServerFirst: 'Preferir letras del servidor', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 79f997d3..ea8983a1 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -537,6 +537,11 @@ export const frTranslation = { 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é.', + discordTemplates: 'Modèles de texte personnalisés', + discordTemplatesDesc: 'Personnalisez les informations affichées sur votre profil Discord. Variables : {title}, {artist}, {album}', + discordTemplateDetails: 'Ligne principale (details)', + discordTemplateState: 'Ligne secondaire (state)', + discordTemplateLargeText: 'Info-bulle album (largeText)', 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 079c19db..a614cd2c 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -536,6 +536,11 @@ export const nbTranslation = { 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.', + discordTemplates: 'Egendefinerte tekstmaler', + discordTemplatesDesc: 'Tilpass hvilken informasjon som vises på Discord-profilen din. Variabler: {title}, {artist}, {album}', + discordTemplateDetails: 'Primær linje (details)', + discordTemplateState: 'Sekundær linje (state)', + discordTemplateLargeText: 'Album-verktøytips (largeText)', 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 dfe68021..d8c5ab85 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -536,6 +536,11 @@ export const nlTranslation = { 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.', + discordTemplates: 'Aangepaste tekstsjablonen', + discordTemplatesDesc: 'Pas aan welke informatie wordt weergegeven op je Discord-profiel. Variabelen: {title}, {artist}, {album}', + discordTemplateDetails: 'Primaire regel (details)', + discordTemplateState: 'Secundaire regel (state)', + discordTemplateLargeText: 'Album-tooltip (largeText)', 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 b97d3427..33ae4ffb 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -559,6 +559,11 @@ export const ruTranslation = { 'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.', discordAppleCovers: 'Загружать обложки через Apple Music для Discord', discordAppleCoversDesc: 'Отправляет имя исполнителя и альбома в Apple Music API для поиска обложки для профиля Discord. По умолчанию отключено из соображений конфиденциальности.', + discordTemplates: 'Пользовательские шаблоны текста', + discordTemplatesDesc: 'Настройте, какая информация отображается в профиле Discord. Переменные: {title}, {artist}, {album}', + discordTemplateDetails: 'Основная строка (details)', + discordTemplateState: 'Вторичная строка (state)', + discordTemplateLargeText: 'Подсказка альбома (largeText)', nowPlayingEnabled: 'Показывать в «Сейчас играет»', nowPlayingEnabledDesc: 'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 9711380c..8de63763 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -532,6 +532,11 @@ export const zhTranslation = { discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。', discordAppleCovers: '通过 Apple Music 为 Discord 获取封面', discordAppleCoversDesc: '将艺术家和专辑名称发送至 Apple 搜索 API,以为 Discord 个人资料查找封面图片。出于隐私考虑,默认禁用。', + discordTemplates: '自定义文本模板', + discordTemplatesDesc: '自定义在 Discord 个人资料上显示的信息。变量:{title}, {artist}, {album}', + discordTemplateDetails: '主行 (details)', + discordTemplateState: '副行 (state)', + discordTemplateLargeText: '专辑提示 (largeText)', nowPlayingEnabled: '在实时窗口中显示', nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。', lyricsServerFirst: '优先使用服务器歌词', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c78bab79..3b29943c 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1155,6 +1155,41 @@ export default function Settings() { +
+
+
{t('settings.discordTemplates')}
+
{t('settings.discordTemplatesDesc')}
+
+ + auth.setDiscordTemplateDetails(e.target.value)} + placeholder="{artist} - {title}" + /> +
+
+ + auth.setDiscordTemplateState(e.target.value)} + placeholder="{album}" + /> +
+
+ + auth.setDiscordTemplateLargeText(e.target.value)} + placeholder="{album}" + /> +
+
)}
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 24c0794f..408f1aa6 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -61,6 +61,9 @@ interface AuthState { minimizeToTray: boolean; discordRichPresence: boolean; enableAppleMusicCoversDiscord: boolean; + discordTemplateDetails: string; + discordTemplateState: string; + discordTemplateLargeText: string; useCustomTitlebar: boolean; nowPlayingEnabled: boolean; lyricsServerFirst: boolean; @@ -187,6 +190,9 @@ interface AuthState { setMinimizeToTray: (v: boolean) => void; setDiscordRichPresence: (v: boolean) => void; setEnableAppleMusicCoversDiscord: (v: boolean) => void; + setDiscordTemplateDetails: (v: string) => void; + setDiscordTemplateState: (v: string) => void; + setDiscordTemplateLargeText: (v: string) => void; setUseCustomTitlebar: (v: boolean) => void; setNowPlayingEnabled: (v: boolean) => void; setLyricsServerFirst: (v: boolean) => void; @@ -285,6 +291,9 @@ export const useAuthStore = create()( minimizeToTray: false, discordRichPresence: false, enableAppleMusicCoversDiscord: false, + discordTemplateDetails: '{artist} - {title}', + discordTemplateState: '{album}', + discordTemplateLargeText: '{album}', useCustomTitlebar: false, nowPlayingEnabled: false, lyricsServerFirst: true, @@ -407,6 +416,9 @@ export const useAuthStore = create()( setMinimizeToTray: (v) => set({ minimizeToTray: v }), setDiscordRichPresence: (v) => set({ discordRichPresence: v }), setEnableAppleMusicCoversDiscord: (v) => set({ enableAppleMusicCoversDiscord: v }), + setDiscordTemplateDetails: (v) => set({ discordTemplateDetails: v }), + setDiscordTemplateState: (v) => set({ discordTemplateState: v }), + setDiscordTemplateLargeText: (v) => set({ discordTemplateLargeText: v }), setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }), setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }), setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }), diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 08bff3c0..db564b59 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -745,16 +745,28 @@ export function initAudioListeners(): () => void { let discordPrevTrackId: string | null = null; let discordPrevIsPlaying: boolean | null = null; let discordPrevFetchCovers: boolean | null = null; + let discordPrevTemplateDetails: string | null = null; + let discordPrevTemplateState: string | null = null; + let discordPrevTemplateLargeText: string | null = null; function syncDiscord() { const { currentTrack, isPlaying, currentTime } = usePlayerStore.getState(); - const { discordRichPresence, enableAppleMusicCoversDiscord } = useAuthStore.getState(); + const { + discordRichPresence, + enableAppleMusicCoversDiscord, + discordTemplateDetails, + discordTemplateState, + discordTemplateLargeText, + } = useAuthStore.getState(); if (!discordRichPresence || !currentTrack) { if (discordPrevTrackId !== null) { discordPrevTrackId = null; discordPrevIsPlaying = null; discordPrevFetchCovers = null; + discordPrevTemplateDetails = null; + discordPrevTemplateState = null; + discordPrevTemplateLargeText = null; invoke('discord_clear_presence').catch(() => {}); } return; @@ -763,24 +775,31 @@ export function initAudioListeners(): () => void { const trackChanged = currentTrack.id !== discordPrevTrackId; const playingChanged = isPlaying !== discordPrevIsPlaying; const coversSettingChanged = enableAppleMusicCoversDiscord !== discordPrevFetchCovers; - if (!trackChanged && !playingChanged && !coversSettingChanged) return; + const detailsTemplateChanged = discordTemplateDetails !== discordPrevTemplateDetails; + const stateTemplateChanged = discordTemplateState !== discordPrevTemplateState; + const largeTextTemplateChanged = discordTemplateLargeText !== discordPrevTemplateLargeText; + if (!trackChanged && !playingChanged && !coversSettingChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged) return; discordPrevTrackId = currentTrack.id; discordPrevIsPlaying = isPlaying; discordPrevFetchCovers = enableAppleMusicCoversDiscord; + discordPrevTemplateDetails = discordTemplateDetails; + discordPrevTemplateState = discordTemplateState; + discordPrevTemplateLargeText = discordTemplateLargeText; 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 clears the timer. elapsedSecs: isPlaying ? currentTime : null, // coverArtUrl is intentionally not passed — Subsonic URLs require auth. // iTunes cover fetching is only done when explicitly opted in. coverArtUrl: null, fetchItunesCovers: enableAppleMusicCoversDiscord, + detailsTemplate: discordTemplateDetails, + stateTemplate: discordTemplateState, + largeTextTemplate: discordTemplateLargeText, }).catch(() => {}); }