mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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)
This commit is contained in:
committed by
GitHub
parent
403979b35d
commit
455aec4def
@@ -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
|
||||
|
||||
@@ -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<String>,
|
||||
state_template: Option<String>,
|
||||
large_text_template: Option<String>,
|
||||
name_template: Option<String>,
|
||||
) -> 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");
|
||||
}
|
||||
|
||||
|
||||
@@ -202,6 +202,16 @@ export function IntegrationsTab() {
|
||||
<div style={{ paddingTop: 8 }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 13, marginBottom: 8 }}>{t('settings.discordTemplates')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12 }}>{t('settings.discordTemplatesDesc')}</div>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 12 }}>{t('settings.discordTemplateName')}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={auth.discordTemplateName}
|
||||
onChange={e => auth.setDiscordTemplateName(e.target.value)}
|
||||
placeholder="{title}"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 12 }}>{t('settings.discordTemplateDetails')}</label>
|
||||
<input
|
||||
|
||||
@@ -337,6 +337,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Local library index (preview): SQLite per-server track store, background initial and delta sync, live and Advanced Search against the local index, integrity verify and auto-reconcile on count drop (PR #846)',
|
||||
'Server index-key rebuild: safe dual-DB migration flow, per-server analysis strategy controls, and playback/index scope hardening (PR #864)',
|
||||
'Settings: opt-in Linux input-focus repaint — workaround for the WebKitGTK 2.50.x text-field freeze (PR #884)',
|
||||
'Discord Rich Presence: configurable activity-name template — member list shows the playing track instead of "Psysonic" (PR #885)',
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -225,6 +225,7 @@ export const settings = {
|
||||
discordTemplateDetails: 'Primäre Zeile (details)',
|
||||
discordTemplateState: 'Sekundäre Zeile (state)',
|
||||
discordTemplateLargeText: 'Album-Tooltip (largeText)',
|
||||
discordTemplateName: 'Userliste-Zeile (name)',
|
||||
nowPlayingEnabled: 'Im Livefenster anzeigen',
|
||||
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
|
||||
enableBandsintown: 'Bandsintown-Tourdaten',
|
||||
|
||||
@@ -248,6 +248,7 @@ export const settings = {
|
||||
discordTemplateDetails: 'Primary line (details)',
|
||||
discordTemplateState: 'Secondary line (state)',
|
||||
discordTemplateLargeText: 'Album tooltip (largeText)',
|
||||
discordTemplateName: 'User list line (name)',
|
||||
nowPlayingEnabled: 'Show in Now Playing',
|
||||
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
|
||||
enableBandsintown: 'Bandsintown tour dates',
|
||||
|
||||
@@ -224,6 +224,7 @@ export const settings = {
|
||||
discordTemplateDetails: 'Línea principal (details)',
|
||||
discordTemplateState: 'Línea secundaria (state)',
|
||||
discordTemplateLargeText: 'Tooltip del álbum (largeText)',
|
||||
discordTemplateName: 'Línea de la lista (name)',
|
||||
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.',
|
||||
enableBandsintown: 'Fechas de gira de Bandsintown',
|
||||
|
||||
@@ -222,6 +222,7 @@ export const settings = {
|
||||
discordTemplateDetails: 'Ligne principale (details)',
|
||||
discordTemplateState: 'Ligne secondaire (state)',
|
||||
discordTemplateLargeText: 'Info-bulle album (largeText)',
|
||||
discordTemplateName: 'Ligne de la liste (name)',
|
||||
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.',
|
||||
enableBandsintown: 'Dates de tournée Bandsintown',
|
||||
|
||||
@@ -221,6 +221,7 @@ export const settings = {
|
||||
discordTemplateDetails: 'Primær linje (details)',
|
||||
discordTemplateState: 'Sekundær linje (state)',
|
||||
discordTemplateLargeText: 'Album-verktøytips (largeText)',
|
||||
discordTemplateName: 'Brukerliste-linje (name)',
|
||||
nowPlayingEnabled: 'Vis i "Nå spiller"',
|
||||
nowPlayingEnabledDesc: 'Send sporet som spilles av til tjenerens live-lyttervisning. Deaktiver for å stoppe sending av avspillingsdata.',
|
||||
enableBandsintown: 'Bandsintown-turnédatoer',
|
||||
|
||||
@@ -222,6 +222,7 @@ export const settings = {
|
||||
discordTemplateDetails: 'Primaire regel (details)',
|
||||
discordTemplateState: 'Secundaire regel (state)',
|
||||
discordTemplateLargeText: 'Album-tooltip (largeText)',
|
||||
discordTemplateName: 'Gebruikerslijst-regel (name)',
|
||||
nowPlayingEnabled: 'Weergeven in live-venster',
|
||||
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
|
||||
enableBandsintown: 'Bandsintown-tourdata',
|
||||
|
||||
@@ -227,6 +227,7 @@ export const settings = {
|
||||
discordTemplateDetails: 'Linia principală (detalii)',
|
||||
discordTemplateState: 'Linia secundară (status)',
|
||||
discordTemplateLargeText: 'Tooltip album (largeText)',
|
||||
discordTemplateName: 'Linia din listă (name)',
|
||||
nowPlayingEnabled: 'Se afișează în Now Playing',
|
||||
nowPlayingEnabledDesc: 'Difuzează piesa redată curent către vizualizatorul de ascultare live a serverului. Dezactivează pentru a opri trimiterea datelor de redare.',
|
||||
enableBandsintown: 'Date de turneu Bandsintown',
|
||||
|
||||
@@ -252,6 +252,7 @@ export const settings = {
|
||||
discordTemplateDetails: 'Основная строка (details)',
|
||||
discordTemplateState: 'Вторичная строка (state)',
|
||||
discordTemplateLargeText: 'Подсказка альбома (largeText)',
|
||||
discordTemplateName: 'Строка в списке пользователей (name)',
|
||||
nowPlayingEnabled: 'Показывать в «Сейчас играет»',
|
||||
nowPlayingEnabledDesc:
|
||||
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
|
||||
|
||||
@@ -221,6 +221,7 @@ export const settings = {
|
||||
discordTemplateDetails: '主行 (details)',
|
||||
discordTemplateState: '副行 (state)',
|
||||
discordTemplateLargeText: '专辑提示 (largeText)',
|
||||
discordTemplateName: '用户列表行 (name)',
|
||||
nowPlayingEnabled: '在实时窗口中显示',
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
enableBandsintown: 'Bandsintown 巡演日期',
|
||||
|
||||
@@ -18,6 +18,7 @@ export function setupDiscordPresence(): () => 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<string, string | null>();
|
||||
|
||||
@@ -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(() => {});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, (v: unknown) => void>)[setter](value);
|
||||
expect((useAuthStore.getState() as unknown as Record<string, unknown>)[key]).toBe(value);
|
||||
|
||||
@@ -75,6 +75,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
discordTemplateDetails: '{artist}',
|
||||
discordTemplateState: '{title}',
|
||||
discordTemplateLargeText: '{album}',
|
||||
discordTemplateName: '{title}',
|
||||
useCustomTitlebar: false,
|
||||
preloadMiniPlayer: false,
|
||||
linuxWebkitKineticScroll: true,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user