From 222742b152b92a9b639fda216330e5e0728f8ed4 Mon Sep 17 00:00:00 2001 From: Kyle Schultz Date: Fri, 14 Aug 2026 12:30:21 -0700 Subject: [PATCH 01/11] feat: Add opt-in Discord Rich Presence --- .gitignore | 1 + README.md | 2 ++ src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++ src/App.tsx | 51 ++++++++++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+) diff --git a/.gitignore b/.gitignore index e89dd49..1334dfd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist-ssr *.local src-tauri/target +src-tauri/gen .DS_Store .idea diff --git a/README.md b/README.md index 4fd3bc2..9abb943 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ Click any screenshot to open it full size. Prism stores your server connection and playback preferences locally. Optional anonymous analytics are opt-in. They only include app and install details, plus aggregate library counts; they do not include your account or playback data. +Discord Rich Presence is also opt-in and desktop-only. When enabled, Prism sends the current track, artist, album, and playback state directly to the Discord app running on the same device. Prism does not use a Discord bot, server integration, account token, or client secret. + ## License [GNU General Public License v3.0](./LICENSE) — free to use, modify, and share. If you distribute a modified version, it needs to stay under the same license. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4803162..959a74b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +discord-rich-presence = "1.1.0" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri = { version = "2", features = [] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9c6d772..a5adc69 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,69 @@ +use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient}; +use serde::Deserialize; + +const DISCORD_CLIENT_ID: &str = "1537904664740364418"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DiscordPresence { + title: String, + artist: String, + album: Option, + playing: bool, + started_at: Option, +} + +#[tauri::command] +fn update_discord_presence(presence: DiscordPresence) { + let mut client = DiscordIpcClient::new(DISCORD_CLIENT_ID); + if client.connect().is_err() { + return; + } + + let state = presence.album.filter(|album| !album.trim().is_empty()).map_or_else( + || presence.artist.clone(), + |album| format!("{} · {album}", presence.artist), + ); + let details = if presence.playing { + presence.title + } else { + format!("Paused · {}", presence.title) + }; + let mut activity = activity::Activity::new() + .details(details) + .state(state) + .assets( + activity::Assets::new() + .large_image("prism-player") + .large_text("Prism Player"), + ); + + if presence.playing { + if let Some(started_at) = presence.started_at { + activity = activity.timestamps(activity::Timestamps::new().start(started_at)); + } + } + + let _ = client.set_activity(activity); + let _ = client.close(); +} + +#[tauri::command] +fn clear_discord_presence() { + let mut client = DiscordIpcClient::new(DISCORD_CLIENT_ID); + if client.connect().is_err() { + return; + } + + let _ = client.clear_activity(); + let _ = client.close(); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .invoke_handler(tauri::generate_handler![update_discord_presence, clear_discord_presence]) .run(tauri::generate_context!()) .expect("error while running Prism Player"); } diff --git a/src/App.tsx b/src/App.tsx index 0fdaa88..be8c3ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { invoke } from "@tauri-apps/api/core"; import type { CSSProperties, FormEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import { @@ -98,6 +99,10 @@ function isVersionNewer(candidate: string, current: string) { return false; } +function isTauriDesktopApp() { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + type NavidromeConfig = { serverUrl: string; username: string; @@ -110,6 +115,7 @@ type AppSettings = { defaultArtistView: ArtistViewMode; analyticsEnabled: boolean; analyticsPromptDismissed: boolean; + discordPresenceEnabled: boolean; updateDismissedVersion: string; coverWashEnabled: boolean; lowPerformanceMode: boolean; @@ -428,6 +434,7 @@ const defaultSettings: AppSettings = { defaultArtistView: "list", analyticsEnabled: false, analyticsPromptDismissed: false, + discordPresenceEnabled: false, updateDismissedVersion: "", coverWashEnabled: true, lowPerformanceMode: false, @@ -587,6 +594,7 @@ function loadStoredSettings(): AppSettings { defaultArtistView: parsed.defaultArtistView === "art" ? "art" : "list", analyticsEnabled: Boolean(parsed.analyticsEnabled), analyticsPromptDismissed: Boolean(parsed.analyticsPromptDismissed), + discordPresenceEnabled: Boolean(parsed.discordPresenceEnabled), updateDismissedVersion: typeof parsed.updateDismissedVersion === "string" ? parsed.updateDismissedVersion : "", coverWashEnabled: parsed.coverWashEnabled ?? defaultSettings.coverWashEnabled, lowPerformanceMode: Boolean(parsed.lowPerformanceMode), @@ -1816,6 +1824,7 @@ export function App() { const pendingResumePositionRef = useRef(initialPlaybackSnapshot?.position ?? 0); const lastPlaybackPersistRef = useRef(0); const lastPlaybackPersistTrackRef = useRef(""); + const [discordPresenceSyncNonce, setDiscordPresenceSyncNonce] = useState(0); const hasConfig = Boolean(config); const currentTrack = queue[currentIndex] ?? null; @@ -2913,6 +2922,7 @@ export function App() { if (!audio || !Number.isFinite(nextPosition)) return; audio.currentTime = nextPosition; setPosition(nextPosition); + setDiscordPresenceSyncNonce((nonce) => nonce + 1); } function handleLoadedMetadata(duration: number) { @@ -3149,6 +3159,29 @@ export function App() { setActiveView("settings"); } + useEffect(() => { + if (!isTauriDesktopApp()) return; + + if (!appSettings.discordPresenceEnabled || activePlaybackSource !== "local" || !currentTrack) { + void invoke("clear_discord_presence").catch(() => undefined); + return; + } + + void invoke("update_discord_presence", { + presence: { + title: currentTrack.title, + artist: currentTrack.artist ?? "Unknown artist", + album: currentTrack.album ?? null, + playing: isPlaying, + startedAt: isPlaying ? Date.now() - Math.round(position * 1000) : null, + }, + }).catch(() => undefined); + }, [activePlaybackSource, appSettings.discordPresenceEnabled, currentTrack?.id, discordPresenceSyncNonce, isPlaying]); + + useEffect(() => () => { + if (isTauriDesktopApp()) void invoke("clear_discord_presence").catch(() => undefined); + }, []); + useEffect(() => { if (config) { void refreshLibrary(config); @@ -5528,6 +5561,24 @@ function SettingsView({ : null} {activeTab === "privacy" ?
+
+
+

Discord

+

Rich Presence

+
+ +
+ +

+ Desktop only. Prism sends the current track, artist, album, and playback state directly to the Discord app running on this device. Nothing is sent to Prism or a Discord bot. +

Analytics

From eea15ca4e96f44a56b06ab6bb958fa8cc25d450a Mon Sep 17 00:00:00 2001 From: Kyle Schultz Date: Fri, 14 Aug 2026 13:46:20 -0700 Subject: [PATCH 02/11] fix: Keep Discord IPC off the app UI thread --- src-tauri/src/lib.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a5adc69..fa98a9c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,10 @@ struct DiscordPresence { #[tauri::command] fn update_discord_presence(presence: DiscordPresence) { + tauri::async_runtime::spawn_blocking(move || publish_discord_presence(presence)); +} + +fn publish_discord_presence(presence: DiscordPresence) { let mut client = DiscordIpcClient::new(DISCORD_CLIENT_ID); if client.connect().is_err() { return; @@ -50,6 +54,10 @@ fn update_discord_presence(presence: DiscordPresence) { #[tauri::command] fn clear_discord_presence() { + tauri::async_runtime::spawn_blocking(clear_discord_presence_in_background); +} + +fn clear_discord_presence_in_background() { let mut client = DiscordIpcClient::new(DISCORD_CLIENT_ID); if client.connect().is_err() { return; From e92f839a419368a2fc4f7ff6198b759e0d4a4c79 Mon Sep 17 00:00:00 2001 From: Kyle Schultz Date: Fri, 14 Aug 2026 13:58:32 -0700 Subject: [PATCH 03/11] fix: Show Discord Rich Presence connection status --- src-tauri/src/lib.rs | 36 ++++++++++++++++++++++++++++-------- src/App.tsx | 27 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fa98a9c..f49eb87 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use tauri::Emitter; const DISCORD_CLIENT_ID: &str = "1537904664740364418"; @@ -13,16 +14,34 @@ struct DiscordPresence { started_at: Option, } +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct DiscordPresenceStatus { + state: &'static str, + message: String, +} + #[tauri::command] -fn update_discord_presence(presence: DiscordPresence) { - tauri::async_runtime::spawn_blocking(move || publish_discord_presence(presence)); +fn update_discord_presence(app: tauri::AppHandle, presence: DiscordPresence) { + tauri::async_runtime::spawn_blocking(move || { + let status = match publish_discord_presence(presence) { + Ok(()) => DiscordPresenceStatus { + state: "connected", + message: "Connected to Discord.".to_string(), + }, + Err(error) => DiscordPresenceStatus { + state: "unavailable", + message: format!("Discord unavailable: {error}"), + }, + }; + + let _ = app.emit("discord-presence-status", status); + }); } -fn publish_discord_presence(presence: DiscordPresence) { +fn publish_discord_presence(presence: DiscordPresence) -> Result<(), String> { let mut client = DiscordIpcClient::new(DISCORD_CLIENT_ID); - if client.connect().is_err() { - return; - } + client.connect().map_err(|error| error.to_string())?; let state = presence.album.filter(|album| !album.trim().is_empty()).map_or_else( || presence.artist.clone(), @@ -48,8 +67,9 @@ fn publish_discord_presence(presence: DiscordPresence) { } } - let _ = client.set_activity(activity); + client.set_activity(activity).map_err(|error| error.to_string())?; let _ = client.close(); + Ok(()) } #[tauri::command] diff --git a/src/App.tsx b/src/App.tsx index be8c3ad..50d5117 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import type { CSSProperties, FormEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import { @@ -60,6 +61,7 @@ type ArtistViewMode = "art" | "list"; type RepeatMode = "off" | "all" | "one"; type RightPanelTab = "queue" | "nowPlaying" | "lyrics"; type LyricsStatus = "idle" | "loading" | "ready" | "empty" | "error"; +type DiscordPresenceStatus = "idle" | "connecting" | "connected" | "unavailable"; type SongSortKey = "title" | "artist" | "album" | "duration" | "track"; type SongSortDirection = "asc" | "desc"; @@ -1825,6 +1827,7 @@ export function App() { const lastPlaybackPersistRef = useRef(0); const lastPlaybackPersistTrackRef = useRef(""); const [discordPresenceSyncNonce, setDiscordPresenceSyncNonce] = useState(0); + const [discordPresenceStatus, setDiscordPresenceStatus] = useState("idle"); const hasConfig = Boolean(config); const currentTrack = queue[currentIndex] ?? null; @@ -3163,10 +3166,12 @@ export function App() { if (!isTauriDesktopApp()) return; if (!appSettings.discordPresenceEnabled || activePlaybackSource !== "local" || !currentTrack) { + setDiscordPresenceStatus("idle"); void invoke("clear_discord_presence").catch(() => undefined); return; } + setDiscordPresenceStatus("connecting"); void invoke("update_discord_presence", { presence: { title: currentTrack.title, @@ -3182,6 +3187,19 @@ export function App() { if (isTauriDesktopApp()) void invoke("clear_discord_presence").catch(() => undefined); }, []); + useEffect(() => { + if (!isTauriDesktopApp()) return; + + let unlisten: (() => void) | undefined; + void listen<{ state: DiscordPresenceStatus }>("discord-presence-status", (event) => { + setDiscordPresenceStatus(event.payload.state); + }).then((nextUnlisten) => { + unlisten = nextUnlisten; + }); + + return () => unlisten?.(); + }, []); + useEffect(() => { if (config) { void refreshLibrary(config); @@ -4027,6 +4045,7 @@ export function App() { status={status} statusMessage={statusMessage} appSettings={appSettings} + discordPresenceStatus={discordPresenceStatus} activeTab={settingsTab} setActiveTab={selectSettingsTab} updateAppSettings={updateAppSettings} @@ -5311,6 +5330,7 @@ function SettingsView({ status, statusMessage, appSettings, + discordPresenceStatus, activeTab, setActiveTab, updateAppSettings, @@ -5331,6 +5351,7 @@ function SettingsView({ status: ConnectionStatus; statusMessage: string; appSettings: AppSettings; + discordPresenceStatus: DiscordPresenceStatus; activeTab: SettingsTab; setActiveTab: (tab: SettingsTab) => void; updateAppSettings: (settings: AppSettings) => void; @@ -5579,6 +5600,12 @@ function SettingsView({

Desktop only. Prism sends the current track, artist, album, and playback state directly to the Discord app running on this device. Nothing is sent to Prism or a Discord bot.

+ {appSettings.discordPresenceEnabled ?

+ {discordPresenceStatus === "connecting" ? "Connecting to Discord…" : null} + {discordPresenceStatus === "connected" ? "Connected to Discord." : null} + {discordPresenceStatus === "unavailable" ? "Discord is unavailable. Keep the desktop app open and try again." : null} + {discordPresenceStatus === "idle" ? "Start local playback to update Discord." : null} +

: null}

Analytics

From ef7cc686035a708d0049d7ebbda4605c0951c498 Mon Sep 17 00:00:00 2001 From: Kyle Schultz Date: Fri, 14 Aug 2026 14:10:15 -0700 Subject: [PATCH 04/11] fix: Keep Discord activity session alive --- src-tauri/src/lib.rs | 59 +++++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f49eb87..c200e14 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,11 @@ use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient}; use serde::{Deserialize, Serialize}; -use tauri::Emitter; +use std::sync::Mutex; +use tauri::{Emitter, Manager}; const DISCORD_CLIENT_ID: &str = "1537904664740364418"; -#[derive(Deserialize)] +#[derive(Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct DiscordPresence { title: String, @@ -21,10 +22,15 @@ struct DiscordPresenceStatus { message: String, } +#[derive(Default)] +struct DiscordPresenceClient { + client: Mutex>, +} + #[tauri::command] fn update_discord_presence(app: tauri::AppHandle, presence: DiscordPresence) { tauri::async_runtime::spawn_blocking(move || { - let status = match publish_discord_presence(presence) { + let status = match publish_discord_presence(&app, presence) { Ok(()) => DiscordPresenceStatus { state: "connected", message: "Connected to Discord.".to_string(), @@ -39,9 +45,26 @@ fn update_discord_presence(app: tauri::AppHandle, presence: DiscordPresence) { }); } -fn publish_discord_presence(presence: DiscordPresence) -> Result<(), String> { - let mut client = DiscordIpcClient::new(DISCORD_CLIENT_ID); - client.connect().map_err(|error| error.to_string())?; +fn publish_discord_presence(app: &tauri::AppHandle, presence: DiscordPresence) -> Result<(), String> { + let state = app.state::(); + let mut client = state.client.lock().map_err(|_| "Discord IPC lock failed".to_string())?; + if client.is_none() { + let mut new_client = DiscordIpcClient::new(DISCORD_CLIENT_ID); + new_client.connect().map_err(|error| error.to_string())?; + *client = Some(new_client); + } + + if client.as_mut().expect("Discord client is connected").set_activity(build_discord_activity(presence.clone())).is_err() { + let mut replacement = DiscordIpcClient::new(DISCORD_CLIENT_ID); + replacement.connect().map_err(|error| error.to_string())?; + replacement.set_activity(build_discord_activity(presence)).map_err(|error| error.to_string())?; + *client = Some(replacement); + } + + Ok(()) +} + +fn build_discord_activity(presence: DiscordPresence) -> activity::Activity<'static> { let state = presence.album.filter(|album| !album.trim().is_empty()).map_or_else( || presence.artist.clone(), @@ -67,30 +90,32 @@ fn publish_discord_presence(presence: DiscordPresence) -> Result<(), String> { } } - client.set_activity(activity).map_err(|error| error.to_string())?; - let _ = client.close(); - Ok(()) + activity } #[tauri::command] -fn clear_discord_presence() { - tauri::async_runtime::spawn_blocking(clear_discord_presence_in_background); +fn clear_discord_presence(app: tauri::AppHandle) { + tauri::async_runtime::spawn_blocking(move || clear_discord_presence_in_background(&app)); } -fn clear_discord_presence_in_background() { - let mut client = DiscordIpcClient::new(DISCORD_CLIENT_ID); - if client.connect().is_err() { +fn clear_discord_presence_in_background(app: &tauri::AppHandle) { + let state = app.state::(); + let Ok(mut client) = state.client.lock() else { return; - } + }; - let _ = client.clear_activity(); - let _ = client.close(); + if let Some(active_client) = client.as_mut() { + if active_client.clear_activity().is_err() { + *client = None; + } + } } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .manage(DiscordPresenceClient::default()) .invoke_handler(tauri::generate_handler![update_discord_presence, clear_discord_presence]) .run(tauri::generate_context!()) .expect("error while running Prism Player"); From 9e2b279ee06b93b9202ed54a08ffb90cc7540392 Mon Sep 17 00:00:00 2001 From: Kyle Schultz Date: Fri, 14 Aug 2026 14:43:11 -0700 Subject: [PATCH 05/11] feat: mark Discord activity as listening --- src-tauri/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c200e14..3144c1a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -76,6 +76,8 @@ fn build_discord_activity(presence: DiscordPresence) -> activity::Activity<'stat format!("Paused · {}", presence.title) }; let mut activity = activity::Activity::new() + .activity_type(activity::ActivityType::Listening) + .status_display_type(activity::StatusDisplayType::Details) .details(details) .state(state) .assets( From d9dceab86da7b26fb5ad4c5192d673ece0b8076d Mon Sep 17 00:00:00 2001 From: Kyle Schultz Date: Fri, 14 Aug 2026 15:09:13 -0700 Subject: [PATCH 06/11] chore: Point Discord links to Prism Player Bot invite --- README.md | 4 ++-- src/App.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9abb943..d8e1872 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Prism Player -[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.gg/aDEBQq3XtN) +[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.gg/hzeAqu7EwF) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) [![GitHub Sponsors](https://img.shields.io/github/sponsors/kylejschultz?label=Sponsor&logo=githubsponsors&color=ea4aaa)](https://github.com/sponsors/kylejschultz) [![Ko-fi](https://img.shields.io/badge/Ko--fi-support%20the%20project-FF5E5B?logo=ko-fi&logoColor=white)](https://ko-fi.com/kylejschultz) @@ -17,7 +17,7 @@ It is still early, but there are builds for macOS and Windows. ![Prism Player home screen](docs/images/home.png) -> **Need help or want to talk about Prism?** [Join the Discord](https://discord.gg/aDEBQq3XtN). It is the quickest place for questions, ideas, and bug reports. +> **Need help or want to talk about Prism?** [Join the Discord](https://discord.gg/hzeAqu7EwF). It is the quickest place for questions, ideas, and bug reports. ## Get Prism diff --git a/src/App.tsx b/src/App.tsx index 50d5117..07ce479 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -390,7 +390,7 @@ const ANALYTICS_LAST_PING_KEY = "prism-player.analyticsLastPing"; const PRISM_RELEASES_URL = "https://github.com/kylejschultz/prism-player/releases/latest"; const PRISM_LATEST_RELEASE_API = "https://api.github.com/repos/kylejschultz/prism-player/releases/latest"; const PRISM_REPOSITORY_URL = "https://github.com/kylejschultz/prism-player"; -const PRISM_DISCORD_URL = "https://discord.gg/aDEBQq3XtN"; +const PRISM_DISCORD_URL = "https://discord.gg/hzeAqu7EwF"; const APP_VERSION = packageJson.version; const APP_COMMIT_SHA = __APP_COMMIT_SHA__; const BEACON_ENDPOINT = "https://beacon.kjschultz.com/ping"; From 8d73a55d643d93adc47d17d58931b806293e3f39 Mon Sep 17 00:00:00 2001 From: Kyle Schultz Date: Fri, 14 Aug 2026 15:21:04 -0700 Subject: [PATCH 07/11] fix: Keep the Prism viewport from page scrolling --- src/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles.css b/src/styles.css index 498eb0c..5c313a6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -17,7 +17,7 @@ body { min-width: 980px; min-height: 640px; margin: 0; - overflow: visible; + overflow: hidden; } ::-webkit-scrollbar { From f9b0848eaa7ec98405b1cce4ae686ea6312b84e7 Mon Sep 17 00:00:00 2001 From: Kyle Schultz Date: Fri, 14 Aug 2026 16:22:37 -0700 Subject: [PATCH 08/11] fix: Add Presence links and close the player footer seam --- src-tauri/src/lib.rs | 4 ++++ src/styles.css | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3144c1a..d551818 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -80,6 +80,10 @@ fn build_discord_activity(presence: DiscordPresence) -> activity::Activity<'stat .status_display_type(activity::StatusDisplayType::Details) .details(details) .state(state) + .buttons(vec![ + activity::Button::new("Get Prism", "https://prismplayer.app"), + activity::Button::new("Join the Discord", "https://discord.gg/hzeAqu7EwF"), + ]) .assets( activity::Assets::new() .large_image("prism-player") diff --git a/src/styles.css b/src/styles.css index 5c313a6..63ee289 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3538,7 +3538,7 @@ button.similar-chip:hover, grid-template-areas: "now center actions"; align-items: center; gap: 14px; - height: 84px; + height: 100%; padding: 8px 18px 10px; border-top: 1px solid rgba(255, 255, 255, 0.1); background: From 73315048cc132b80575ca62b837de01569d71679 Mon Sep 17 00:00:00 2001 From: Kyle Schultz Date: Fri, 14 Aug 2026 18:01:14 -0700 Subject: [PATCH 09/11] feat: Show radio and artist details in Discord --- README.md | 2 +- src-tauri/src/lib.rs | 11 ++++++++--- src/App.tsx | 27 ++++++++++++++++++++------- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d8e1872..64973ee 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Click any screenshot to open it full size. Prism stores your server connection and playback preferences locally. Optional anonymous analytics are opt-in. They only include app and install details, plus aggregate library counts; they do not include your account or playback data. -Discord Rich Presence is also opt-in and desktop-only. When enabled, Prism sends the current track, artist, album, and playback state directly to the Discord app running on the same device. Prism does not use a Discord bot, server integration, account token, or client secret. +Discord Rich Presence is also opt-in and desktop-only. When enabled, Prism sends the current local track or Subwave radio track, artist, album, station, and playback state directly to the Discord app running on the same device. Prism does not use a Discord bot, server integration, account token, or client secret. ## License diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d551818..bcd35c2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,7 @@ struct DiscordPresence { title: String, artist: String, album: Option, + station: Option, playing: bool, started_at: Option, } @@ -70,14 +71,18 @@ fn build_discord_activity(presence: DiscordPresence) -> activity::Activity<'stat || presence.artist.clone(), |album| format!("{} · {album}", presence.artist), ); + let title = presence.title; let details = if presence.playing { - presence.title + presence + .station + .filter(|station| !station.trim().is_empty()) + .map_or(title.clone(), |station| format!("{title} · Live on {station}")) } else { - format!("Paused · {}", presence.title) + format!("Paused · {title}") }; let mut activity = activity::Activity::new() .activity_type(activity::ActivityType::Listening) - .status_display_type(activity::StatusDisplayType::Details) + .status_display_type(activity::StatusDisplayType::State) .details(details) .state(state) .buttons(vec![ diff --git a/src/App.tsx b/src/App.tsx index 07ce479..871409e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3165,23 +3165,36 @@ export function App() { useEffect(() => { if (!isTauriDesktopApp()) return; - if (!appSettings.discordPresenceEnabled || activePlaybackSource !== "local" || !currentTrack) { + if (!appSettings.discordPresenceEnabled) { setDiscordPresenceStatus("idle"); void invoke("clear_discord_presence").catch(() => undefined); return; } + const radioPresence = activePlaybackSource === "radio" && isRadioPlaying && radioNowPlaying; + const localPresence = activePlaybackSource === "local" && currentTrack; + + if (!radioPresence && !localPresence) { + setDiscordPresenceStatus("idle"); + void invoke("clear_discord_presence").catch(() => undefined); + return; + } + + const track = radioPresence || localPresence; + if (!track) return; + setDiscordPresenceStatus("connecting"); void invoke("update_discord_presence", { presence: { - title: currentTrack.title, - artist: currentTrack.artist ?? "Unknown artist", - album: currentTrack.album ?? null, - playing: isPlaying, - startedAt: isPlaying ? Date.now() - Math.round(position * 1000) : null, + title: track.title ?? "Live radio", + artist: track.artist ?? (radioPresence ? "Subwave" : "Unknown artist"), + album: track.album ?? null, + station: radioPresence ? radioStationName(radioStationState, radioStationUrl) : null, + playing: radioPresence ? true : isPlaying, + startedAt: radioPresence || !isPlaying ? null : Date.now() - Math.round(position * 1000), }, }).catch(() => undefined); - }, [activePlaybackSource, appSettings.discordPresenceEnabled, currentTrack?.id, discordPresenceSyncNonce, isPlaying]); + }, [activePlaybackSource, appSettings.discordPresenceEnabled, currentTrack?.id, discordPresenceSyncNonce, isPlaying, isRadioPlaying, radioNowPlaying?.artist, radioNowPlaying?.album, radioNowPlaying?.title, radioStationState, radioStationUrl]); useEffect(() => () => { if (isTauriDesktopApp()) void invoke("clear_discord_presence").catch(() => undefined); From 72e02c5f1575717862c7db5383869c4303ad5c20 Mon Sep 17 00:00:00 2001 From: Kyle Schultz Date: Fri, 14 Aug 2026 21:22:01 -0700 Subject: [PATCH 10/11] fix: Keep radio station details visible in Discord --- src-tauri/src/lib.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bcd35c2..5e57092 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -66,17 +66,18 @@ fn publish_discord_presence(app: &tauri::AppHandle, presence: DiscordPresence) - } fn build_discord_activity(presence: DiscordPresence) -> activity::Activity<'static> { - - let state = presence.album.filter(|album| !album.trim().is_empty()).map_or_else( - || presence.artist.clone(), - |album| format!("{} · {album}", presence.artist), - ); + let station = presence.station.filter(|station| !station.trim().is_empty()); + let state = if let Some(station) = station { + format!("{} · Live on {station}", presence.artist) + } else { + presence + .album + .filter(|album| !album.trim().is_empty()) + .map_or_else(|| presence.artist.clone(), |album| format!("{} · {album}", presence.artist)) + }; let title = presence.title; let details = if presence.playing { - presence - .station - .filter(|station| !station.trim().is_empty()) - .map_or(title.clone(), |station| format!("{title} · Live on {station}")) + title.clone() } else { format!("Paused · {title}") }; From fd145c90e0478e1707aa64cebb1703acf22ea5b6 Mon Sep 17 00:00:00 2001 From: Kyle Schultz Date: Fri, 14 Aug 2026 21:32:04 -0700 Subject: [PATCH 11/11] fix: Keep radio song and artist visible in Discord --- src-tauri/src/lib.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5e57092..bfe70f9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -67,6 +67,7 @@ fn publish_discord_presence(app: &tauri::AppHandle, presence: DiscordPresence) - fn build_discord_activity(presence: DiscordPresence) -> activity::Activity<'static> { let station = presence.station.filter(|station| !station.trim().is_empty()); + let is_radio = station.is_some(); let state = if let Some(station) = station { format!("{} · Live on {station}", presence.artist) } else { @@ -76,14 +77,23 @@ fn build_discord_activity(presence: DiscordPresence) -> activity::Activity<'stat .map_or_else(|| presence.artist.clone(), |album| format!("{} · {album}", presence.artist)) }; let title = presence.title; - let details = if presence.playing { + let track_details = if is_radio { + format!("{title} · {}", presence.artist) + } else { title.clone() + }; + let details = if presence.playing { + track_details } else { - format!("Paused · {title}") + format!("Paused · {track_details}") }; let mut activity = activity::Activity::new() .activity_type(activity::ActivityType::Listening) - .status_display_type(activity::StatusDisplayType::State) + .status_display_type(if is_radio { + activity::StatusDisplayType::Details + } else { + activity::StatusDisplayType::State + }) .details(details) .state(state) .buttons(vec![