From 455aec4def4630414c3c1533418dc9d2e6cd4fb4 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 28 May 2026 20:32:41 +0200 Subject: [PATCH] feat(discord): show track title in Discord member list (configurable name template) (#885) * feat(discord): override activity name in member list with track title (configurable template) The Discord member list and the collapsed Rich Presence card display the activity's `name` field next to the music icon. Without an override Discord falls back to the registered application name ("Psysonic"), so the line reads "Psysonic" while every track plays instead of the actual track title that comparable players show. Add a fourth user-configurable template `discordTemplateName` (Settings -> Integrations -> Discord Rich Presence). The Rust side passes it through `Activity::new().name(...)` when the rendered template is non-empty; an empty template falls back to Discord's default application name. Default template is "{title}". Tauri boundary: additive optional `nameTemplate` field on the existing `discord_update_presence` command. Existing call sites that omit it keep working -- the Rust handler applies the same "{title}" fallback. * i18n(settings): discord name template label in all locales Adds the discordTemplateName label across en, de, fr, es, nl, nb, ro, zh, ru -- shown next to the new "User list line (name)" template input under Discord Rich Presence. * docs(release): CHANGELOG and credits for discord name template (PR #885) --- CHANGELOG.md | 9 ++++++ .../psysonic-integration/src/discord.rs | 30 +++++++++++++++---- src/components/settings/IntegrationsTab.tsx | 10 +++++++ src/config/settingsCredits.ts | 1 + src/locales/de/settings.ts | 1 + src/locales/en/settings.ts | 1 + src/locales/es/settings.ts | 1 + src/locales/fr/settings.ts | 1 + src/locales/nb/settings.ts | 1 + src/locales/nl/settings.ts | 1 + src/locales/ro/settings.ts | 1 + src/locales/ru/settings.ts | 1 + src/locales/zh/settings.ts | 1 + .../audioListenerSetup/discordPresence.ts | 8 ++++- src/store/authDiscordSettingsActions.ts | 2 ++ src/store/authStore.settings.test.ts | 1 + src/store/authStore.ts | 1 + src/store/authStoreTypes.ts | 5 ++++ 18 files changed, 70 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d622e2..fe6ce0f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -258,6 +258,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Discord Rich Presence — track title in the member list + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#885](https://github.com/Psychotoxical/psysonic/pull/885)** + +* The Discord member list and the collapsed presence card now show the playing track next to the music icon instead of the static "Psysonic" label — matches how comparable players appear in the user list. +* New **User list line (name)** template under **Settings → Integrations → Discord Rich Presence**, default `{title}`. Same placeholders as the other templates: `{title}`, `{artist}`, `{album}`. Leaving it empty restores the previous "Psysonic" display. + + + ## Fixed ### Analytics — Opus waveform and loudness analysis diff --git a/src-tauri/crates/psysonic-integration/src/discord.rs b/src-tauri/crates/psysonic-integration/src/discord.rs index 4f1dd5fd..058332d8 100644 --- a/src-tauri/crates/psysonic-integration/src/discord.rs +++ b/src-tauri/crates/psysonic-integration/src/discord.rs @@ -262,12 +262,13 @@ fn apply_template(template: &str, title: &str, artist: &str, album: Option<&str> /// Bundled output of [`compute_discord_text_fields`]. pub(crate) struct DiscordTextFields { + pub name: String, pub details: String, pub state: String, pub large_text: String, } -/// Pure helper: resolve all three configurable Discord text fields, applying +/// Pure helper: resolve all four configurable Discord text fields, applying /// the supplied templates (or falling back to documented defaults). pub(crate) fn compute_discord_text_fields( title: &str, @@ -276,7 +277,9 @@ pub(crate) fn compute_discord_text_fields( details_template: Option<&str>, state_template: Option<&str>, large_text_template: Option<&str>, + name_template: Option<&str>, ) -> DiscordTextFields { + let name = apply_template(name_template.unwrap_or("{title}"), title, artist, album); let details = apply_template( details_template.unwrap_or("{artist} - {title}"), title, @@ -291,6 +294,7 @@ pub(crate) fn compute_discord_text_fields( album, ); DiscordTextFields { + name, details, state, large_text, @@ -318,6 +322,10 @@ pub(crate) fn compute_discord_start_timestamp(elapsed_secs: f64, now_unix_secs: /// Supported placeholders: {title}, {artist}, {album} /// - `large_text_template`: template string for the large image tooltip. Default: "{album}". /// Supported placeholders: {title}, {artist}, {album} +/// - `name_template`: template string overriding Discord's default application name in the +/// user list (e.g. "🎵 Bohemian Rhapsody" instead of "🎵 Psysonic"). Default: "{title}". +/// Empty string falls back to the registered Discord application name. +/// Supported placeholders: {title}, {artist}, {album} #[tauri::command] #[allow(clippy::too_many_arguments)] pub async fn discord_update_presence( @@ -332,6 +340,7 @@ pub async fn discord_update_presence( details_template: Option, state_template: Option, large_text_template: Option, + name_template: Option, ) -> Result<(), String> { // Resolve artwork on a dedicated blocking thread — reqwest::blocking must not // run on the Tokio async executor directly. @@ -377,6 +386,7 @@ pub async fn discord_update_presence( details_template.as_deref(), state_template.as_deref(), large_text_template.as_deref(), + name_template.as_deref(), ); let assets = if let Some(ref url) = artwork_url { @@ -402,8 +412,11 @@ pub async fn discord_update_presence( } // Only reach here when playing - let activity = Activity::new() - .activity_type(ActivityType::Listening) + let mut activity = Activity::new().activity_type(ActivityType::Listening); + if !texts.name.is_empty() { + activity = activity.name(texts.name.as_str()); + } + let activity = activity .details(&texts.details) .state(&texts.state) .assets(assets) @@ -545,7 +558,9 @@ mod tests { #[test] fn text_fields_use_documented_defaults_when_templates_are_none() { - let f = compute_discord_text_fields("Song", "Artist", Some("Album"), None, None, None); + let f = + compute_discord_text_fields("Song", "Artist", Some("Album"), None, None, None, None); + assert_eq!(f.name, "Song"); assert_eq!(f.details, "Artist - Song"); assert_eq!(f.state, "Album"); assert_eq!(f.large_text, "Album"); @@ -560,7 +575,9 @@ mod tests { Some("{title} | {album}"), Some("by {artist}"), Some("{album} ({artist})"), + Some("{title} ({artist})"), ); + assert_eq!(f.name, "Song (Artist)"); assert_eq!(f.details, "Song | Album"); assert_eq!(f.state, "by Artist"); assert_eq!(f.large_text, "Album (Artist)"); @@ -568,8 +585,9 @@ mod tests { #[test] fn text_fields_substitute_empty_for_missing_album() { - let f = compute_discord_text_fields("Song", "Artist", None, None, None, None); + let f = compute_discord_text_fields("Song", "Artist", None, None, None, None, None); // {album} placeholder → empty, but the surrounding template stays. + assert_eq!(f.name, "Song"); assert_eq!(f.details, "Artist - Song"); assert_eq!(f.state, ""); assert_eq!(f.large_text, ""); @@ -584,7 +602,9 @@ mod tests { Some("{artist} – {title}"), None, None, + None, ); + assert_eq!(f.name, "Bohemian Rhapsody"); assert_eq!(f.details, "Queen – Bohemian Rhapsody"); } diff --git a/src/components/settings/IntegrationsTab.tsx b/src/components/settings/IntegrationsTab.tsx index 837970fb..5e964ef0 100644 --- a/src/components/settings/IntegrationsTab.tsx +++ b/src/components/settings/IntegrationsTab.tsx @@ -202,6 +202,16 @@ export function IntegrationsTab() {
{t('settings.discordTemplates')}
{t('settings.discordTemplatesDesc')}
+
+ + auth.setDiscordTemplateName(e.target.value)} + placeholder="{title}" + /> +
void { let discordPrevTemplateDetails: string | null = null; let discordPrevTemplateState: string | null = null; let discordPrevTemplateLargeText: string | null = null; + let discordPrevTemplateName: string | null = null; let discordPrevCoverSource: string | null = null; const discordServerCoverCache = new Map(); @@ -30,6 +31,7 @@ export function setupDiscordPresence(): () => void { discordTemplateDetails, discordTemplateState, discordTemplateLargeText, + discordTemplateName, } = useAuthStore.getState(); if (!discordRichPresence || !currentTrack) { @@ -41,6 +43,7 @@ export function setupDiscordPresence(): () => void { discordPrevTemplateDetails = null; discordPrevTemplateState = null; discordPrevTemplateLargeText = null; + discordPrevTemplateName = null; invoke('discord_clear_presence').catch(() => {}); } return; @@ -52,7 +55,8 @@ export function setupDiscordPresence(): () => void { const detailsTemplateChanged = discordTemplateDetails !== discordPrevTemplateDetails; const stateTemplateChanged = discordTemplateState !== discordPrevTemplateState; const largeTextTemplateChanged = discordTemplateLargeText !== discordPrevTemplateLargeText; - if (!trackChanged && !playingChanged && !coverSourceChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged) return; + const nameTemplateChanged = discordTemplateName !== discordPrevTemplateName; + if (!trackChanged && !playingChanged && !coverSourceChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged && !nameTemplateChanged) return; discordPrevTrackId = currentTrack.id; discordPrevIsPlaying = isPlaying; @@ -61,6 +65,7 @@ export function setupDiscordPresence(): () => void { discordPrevTemplateDetails = discordTemplateDetails; discordPrevTemplateState = discordTemplateState; discordPrevTemplateLargeText = discordTemplateLargeText; + discordPrevTemplateName = discordTemplateName; const sendPresence = (coverArtUrl: string | null) => { invoke('discord_update_presence', { @@ -74,6 +79,7 @@ export function setupDiscordPresence(): () => void { detailsTemplate: discordTemplateDetails, stateTemplate: discordTemplateState, largeTextTemplate: discordTemplateLargeText, + nameTemplate: discordTemplateName, }).catch(() => {}); }; diff --git a/src/store/authDiscordSettingsActions.ts b/src/store/authDiscordSettingsActions.ts index 25d1462e..1862a354 100644 --- a/src/store/authDiscordSettingsActions.ts +++ b/src/store/authDiscordSettingsActions.ts @@ -12,6 +12,7 @@ export function createDiscordSettingsActions(set: SetState): Pick< | 'setDiscordTemplateDetails' | 'setDiscordTemplateState' | 'setDiscordTemplateLargeText' + | 'setDiscordTemplateName' > { return { setDiscordRichPresence: (v) => set({ discordRichPresence: v }), @@ -20,5 +21,6 @@ export function createDiscordSettingsActions(set: SetState): Pick< setDiscordTemplateDetails: (v) => set({ discordTemplateDetails: v }), setDiscordTemplateState: (v) => set({ discordTemplateState: v }), setDiscordTemplateLargeText: (v) => set({ discordTemplateLargeText: v }), + setDiscordTemplateName: (v) => set({ discordTemplateName: v }), }; } diff --git a/src/store/authStore.settings.test.ts b/src/store/authStore.settings.test.ts index afd6be6e..a90d5fc7 100644 --- a/src/store/authStore.settings.test.ts +++ b/src/store/authStore.settings.test.ts @@ -98,6 +98,7 @@ describe('trivial pass-through setters', () => { ['setDiscordTemplateDetails', 'discordTemplateDetails', '{artist} — {title}'], ['setDiscordTemplateState', 'discordTemplateState', '{album}'], ['setDiscordTemplateLargeText', 'discordTemplateLargeText', 'Hi'], + ['setDiscordTemplateName', 'discordTemplateName', '{title} — {artist}'], ])('%s stores a string value', (setter, key, value) => { (useAuthStore.getState() as unknown as Record void>)[setter](value); expect((useAuthStore.getState() as unknown as Record)[key]).toBe(value); diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 5dd9e17f..bbf4466a 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -75,6 +75,7 @@ export const useAuthStore = create()( discordTemplateDetails: '{artist}', discordTemplateState: '{title}', discordTemplateLargeText: '{album}', + discordTemplateName: '{title}', useCustomTitlebar: false, preloadMiniPlayer: false, linuxWebkitKineticScroll: true, diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts index 48587a34..16b4b3ea 100644 --- a/src/store/authStoreTypes.ts +++ b/src/store/authStoreTypes.ts @@ -128,6 +128,10 @@ export interface AuthState { discordTemplateDetails: string; discordTemplateState: string; discordTemplateLargeText: string; + /** Template for Discord activity name (overrides the registered application + * name in the user list / collapsed presence). Default "{title}". + * Empty string falls back to "Psysonic". */ + discordTemplateName: string; useCustomTitlebar: boolean; /** Pre-build the mini-player webview at app start on Linux/macOS so content is available instantly * on first open. Ignored on Windows — that platform always pre-creates as a hang workaround. */ @@ -311,6 +315,7 @@ export interface AuthState { setDiscordTemplateDetails: (v: string) => void; setDiscordTemplateState: (v: string) => void; setDiscordTemplateLargeText: (v: string) => void; + setDiscordTemplateName: (v: string) => void; setUseCustomTitlebar: (v: boolean) => void; setPreloadMiniPlayer: (v: boolean) => void; setLinuxWebkitKineticScroll: (v: boolean) => void;