Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ dist-ssr
*.local

src-tauri/target
src-tauri/gen

.DS_Store
.idea
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [] }
Expand Down
137 changes: 137 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
station: Option<String>,
playing: bool,
started_at: Option<i64>,
}

#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DiscordPresenceStatus {
state: &'static str,
message: String,
}

#[derive(Default)]
struct DiscordPresenceClient {
client: Mutex<Option<DiscordIpcClient>>,
}

#[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::<DiscordPresenceClient>();
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::<DiscordPresenceClient>();
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");
}
93 changes: 92 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand All @@ -110,6 +117,7 @@ type AppSettings = {
defaultArtistView: ArtistViewMode;
analyticsEnabled: boolean;
analyticsPromptDismissed: boolean;
discordPresenceEnabled: boolean;
updateDismissedVersion: string;
coverWashEnabled: boolean;
lowPerformanceMode: boolean;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -428,6 +436,7 @@ const defaultSettings: AppSettings = {
defaultArtistView: "list",
analyticsEnabled: false,
analyticsPromptDismissed: false,
discordPresenceEnabled: false,
updateDismissedVersion: "",
coverWashEnabled: true,
lowPerformanceMode: false,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<DiscordPresenceStatus>("idle");

const hasConfig = Boolean(config);
const currentTrack = queue[currentIndex] ?? null;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -3994,6 +4058,7 @@ export function App() {
status={status}
statusMessage={statusMessage}
appSettings={appSettings}
discordPresenceStatus={discordPresenceStatus}
activeTab={settingsTab}
setActiveTab={selectSettingsTab}
updateAppSettings={updateAppSettings}
Expand Down Expand Up @@ -5278,6 +5343,7 @@ function SettingsView({
status,
statusMessage,
appSettings,
discordPresenceStatus,
activeTab,
setActiveTab,
updateAppSettings,
Expand All @@ -5298,6 +5364,7 @@ function SettingsView({
status: ConnectionStatus;
statusMessage: string;
appSettings: AppSettings;
discordPresenceStatus: DiscordPresenceStatus;
activeTab: SettingsTab;
setActiveTab: (tab: SettingsTab) => void;
updateAppSettings: (settings: AppSettings) => void;
Expand Down Expand Up @@ -5528,6 +5595,30 @@ function SettingsView({
</section> : null}

{activeTab === "privacy" ? <section className="settings-panel">
<div className="panel-heading">
<div>
<p className="eyebrow">Discord</p>
<h3>Rich Presence</h3>
</div>
<MessageCircle size={18} />
</div>
<label className="settings-checkbox">
<input
type="checkbox"
checked={appSettings.discordPresenceEnabled}
onChange={(event) => updateAppSettings({ ...appSettings, discordPresenceEnabled: event.target.checked })}
/>
<span>Show what I’m playing on Discord</span>
</label>
<p className="settings-note">
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.
</p>
{appSettings.discordPresenceEnabled ? <p className={`settings-note ${discordPresenceStatus === "unavailable" ? "bad" : ""}`}>
{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}
</p> : null}
<div className="panel-heading">
<div>
<p className="eyebrow">Analytics</p>
Expand Down
4 changes: 2 additions & 2 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ body {
min-width: 980px;
min-height: 640px;
margin: 0;
overflow: visible;
overflow: hidden;
}

::-webkit-scrollbar {
Expand Down Expand Up @@ -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:
Expand Down