Merge pull request #198 from kveld9/feat/DRP-enhancement

feat: discord rich presence enhancement
This commit is contained in:
Frank Stellmacher
2026-04-17 15:34:18 +02:00
committed by GitHub
12 changed files with 158 additions and 41 deletions
+48 -37
View File
@@ -216,6 +216,16 @@ fn try_connect() -> Option<DiscordIpcClient> {
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<DiscordIpcClient> {
/// - `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<f64>,
cover_art_url: Option<String>,
fetch_itunes_covers: bool,
details_template: Option<String>,
state_template: Option<String>,
large_text_template: Option<String>,
) -> 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;
}
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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:
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
+5
View File
@@ -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: '优先使用服务器歌词',
+35
View File
@@ -1155,6 +1155,41 @@ export default function Settings() {
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div style={{ paddingLeft: 16, paddingTop: 8, paddingBottom: 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.discordTemplateDetails')}</label>
<input
className="input"
type="text"
value={auth.discordTemplateDetails}
onChange={e => auth.setDiscordTemplateDetails(e.target.value)}
placeholder="{artist} - {title}"
/>
</div>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 12 }}>{t('settings.discordTemplateState')}</label>
<input
className="input"
type="text"
value={auth.discordTemplateState}
onChange={e => auth.setDiscordTemplateState(e.target.value)}
placeholder="{album}"
/>
</div>
<div className="form-group">
<label style={{ fontSize: 12 }}>{t('settings.discordTemplateLargeText')}</label>
<input
className="input"
type="text"
value={auth.discordTemplateLargeText}
onChange={e => auth.setDiscordTemplateLargeText(e.target.value)}
placeholder="{album}"
/>
</div>
</div>
</>
)}
<div className="settings-section-divider" />
+12
View File
@@ -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<AuthState>()(
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<AuthState>()(
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 }),
+23 -4
View File
@@ -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(() => {});
}