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;