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..64973ee 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 @@ -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 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 [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..bfe70f9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,144 @@ +use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient}; +use serde::{Deserialize, Serialize}; +use std::sync::Mutex; +use tauri::{Emitter, Manager}; + +const DISCORD_CLIENT_ID: &str = "1537904664740364418"; + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DiscordPresence { + title: String, + artist: String, + album: Option, + station: Option, + playing: bool, + started_at: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct DiscordPresenceStatus { + state: &'static str, + 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(&app, 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(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 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 { + presence + .album + .filter(|album| !album.trim().is_empty()) + .map_or_else(|| presence.artist.clone(), |album| format!("{} · {album}", presence.artist)) + }; + let title = presence.title; + let track_details = if is_radio { + format!("{title} · {}", presence.artist) + } else { + title.clone() + }; + let details = if presence.playing { + track_details + } else { + format!("Paused · {track_details}") + }; + let mut activity = activity::Activity::new() + .activity_type(activity::ActivityType::Listening) + .status_display_type(if is_radio { + activity::StatusDisplayType::Details + } else { + activity::StatusDisplayType::State + }) + .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") + .large_text("Prism Player"), + ); + + if presence.playing { + if let Some(started_at) = presence.started_at { + activity = activity.timestamps(activity::Timestamps::new().start(started_at)); + } + } + + activity +} + +#[tauri::command] +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(app: &tauri::AppHandle) { + let state = app.state::(); + let Ok(mut client) = state.client.lock() else { + return; + }; + + 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"); } diff --git a/src/App.tsx b/src/App.tsx index 0fdaa88..871409e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +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 { @@ -59,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"; @@ -98,6 +101,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 +117,7 @@ type AppSettings = { defaultArtistView: ArtistViewMode; analyticsEnabled: boolean; analyticsPromptDismissed: boolean; + discordPresenceEnabled: boolean; updateDismissedVersion: string; coverWashEnabled: boolean; lowPerformanceMode: boolean; @@ -382,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"; @@ -428,6 +436,7 @@ const defaultSettings: AppSettings = { defaultArtistView: "list", analyticsEnabled: false, analyticsPromptDismissed: false, + discordPresenceEnabled: false, updateDismissedVersion: "", coverWashEnabled: true, lowPerformanceMode: false, @@ -587,6 +596,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 +1826,8 @@ export function App() { const pendingResumePositionRef = useRef(initialPlaybackSnapshot?.position ?? 0); 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; @@ -2913,6 +2925,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 +3162,57 @@ export function App() { setActiveView("settings"); } + useEffect(() => { + if (!isTauriDesktopApp()) return; + + 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: 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, isRadioPlaying, radioNowPlaying?.artist, radioNowPlaying?.album, radioNowPlaying?.title, radioStationState, radioStationUrl]); + + useEffect(() => () => { + 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); @@ -3994,6 +4058,7 @@ export function App() { status={status} statusMessage={statusMessage} appSettings={appSettings} + discordPresenceStatus={discordPresenceStatus} activeTab={settingsTab} setActiveTab={selectSettingsTab} updateAppSettings={updateAppSettings} @@ -5278,6 +5343,7 @@ function SettingsView({ status, statusMessage, appSettings, + discordPresenceStatus, activeTab, setActiveTab, updateAppSettings, @@ -5298,6 +5364,7 @@ function SettingsView({ status: ConnectionStatus; statusMessage: string; appSettings: AppSettings; + discordPresenceStatus: DiscordPresenceStatus; activeTab: SettingsTab; setActiveTab: (tab: SettingsTab) => void; updateAppSettings: (settings: AppSettings) => void; @@ -5528,6 +5595,30 @@ 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. +

+ {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

diff --git a/src/styles.css b/src/styles.css index 498eb0c..63ee289 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 { @@ -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: