merge 6 RPC plugins into one plugin (#890)
* merge 6 RPC plugins into one plugin * Update index.tsx --------- Co-authored-by: thororen <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
parent
a8ae6fd5d5
commit
bb6b23fa6c
24 changed files with 1865 additions and 1666 deletions
|
|
@ -1,251 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
// alot of the code is from JellyfinRPC
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { HeadingSecondary } from "@components/Heading";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { ApplicationAssetUtils, FluxDispatcher, showToast } from "@webpack/common";
|
||||
|
||||
interface ActivityAssets {
|
||||
large_image?: string;
|
||||
large_text?: string;
|
||||
small_image?: string;
|
||||
small_text?: string;
|
||||
}
|
||||
|
||||
interface Activity {
|
||||
state: string;
|
||||
details?: string;
|
||||
timestamps?: {
|
||||
start?: number;
|
||||
};
|
||||
assets?: ActivityAssets;
|
||||
name: string;
|
||||
application_id: string;
|
||||
metadata?: {
|
||||
button_urls?: Array<string>;
|
||||
};
|
||||
type: number;
|
||||
flags: number;
|
||||
}
|
||||
|
||||
interface MediaData {
|
||||
name: string;
|
||||
type: string;
|
||||
author?: string;
|
||||
series?: string;
|
||||
duration?: number;
|
||||
currentTime?: number;
|
||||
progress?: number;
|
||||
url?: string;
|
||||
imageUrl?: string;
|
||||
isFinished?: boolean;
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
serverUrl: {
|
||||
description: "AudioBookShelf server URL (e.g., https://abs.example.com)",
|
||||
type: OptionType.STRING,
|
||||
},
|
||||
username: {
|
||||
description: "AudioBookShelf username",
|
||||
type: OptionType.STRING,
|
||||
},
|
||||
password: {
|
||||
description: "AudioBookShelf password",
|
||||
type: OptionType.STRING,
|
||||
},
|
||||
});
|
||||
|
||||
const applicationId = "1381423044907503636";
|
||||
|
||||
const logger = new Logger("AudioBookShelfRichPresence");
|
||||
|
||||
let authToken: string | null = null;
|
||||
let updateInterval: NodeJS.Timeout | undefined;
|
||||
|
||||
async function getApplicationAsset(key: string): Promise<string> {
|
||||
return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0];
|
||||
}
|
||||
|
||||
function setActivity(activity: Activity | null) {
|
||||
FluxDispatcher.dispatch({
|
||||
type: "LOCAL_ACTIVITY_UPDATE",
|
||||
activity,
|
||||
socketId: "ABSRPC",
|
||||
});
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "AudioBookShelfRichPresence",
|
||||
description: "Rich presence for AudioBookShelf media server",
|
||||
authors: [EquicordDevs.vmohammad],
|
||||
|
||||
settingsAboutComponent: () => (
|
||||
<>
|
||||
<HeadingSecondary>How to connect to AudioBookShelf</HeadingSecondary>
|
||||
<Paragraph>
|
||||
Enter your AudioBookShelf server URL, username, and password to display your currently playing audiobooks as Discord Rich Presence.
|
||||
<br /><br />
|
||||
The plugin will automatically authenticate and fetch your listening progress.
|
||||
</Paragraph>
|
||||
</>
|
||||
),
|
||||
|
||||
settings,
|
||||
|
||||
start() {
|
||||
this.updatePresence();
|
||||
updateInterval = setInterval(() => { this.updatePresence(); }, 10000);
|
||||
},
|
||||
|
||||
stop() {
|
||||
clearInterval(updateInterval);
|
||||
updateInterval = undefined;
|
||||
},
|
||||
|
||||
async authenticate(): Promise<boolean> {
|
||||
if (!settings.store.serverUrl || !settings.store.username || !settings.store.password) {
|
||||
logger.warn("AudioBookShelf server URL, username, or password is not set in settings.");
|
||||
showToast("AudioBookShelf RPC is not configured.", "failure", {
|
||||
duration: 15000,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = settings.store.serverUrl.replace(/\/$/, "");
|
||||
const url = `${baseUrl}/login`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: settings.store.username,
|
||||
password: settings.store.password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw `${res.status} ${res.statusText}`;
|
||||
|
||||
const data = await res.json();
|
||||
authToken = data.user?.token;
|
||||
return !!authToken;
|
||||
} catch (e) {
|
||||
logger.error("Failed to authenticate with AudioBookShelf", e);
|
||||
authToken = null;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchMediaData(): Promise<MediaData | null> {
|
||||
if (!authToken && !(await this.authenticate())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPlayingNow = session => {
|
||||
const now = Date.now();
|
||||
const lastUpdate = session.updatedAt;
|
||||
const diffSeconds = (now - lastUpdate) / 1000;
|
||||
return diffSeconds <= 30;
|
||||
};
|
||||
try {
|
||||
const baseUrl = settings.store.serverUrl!.replace(/\/$/, "");
|
||||
const url = `${baseUrl}/api/me/listening-sessions`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
authToken = null;
|
||||
if (await this.authenticate()) {
|
||||
return this.fetchMediaData();
|
||||
}
|
||||
}
|
||||
throw `${res.status} ${res.statusText}`;
|
||||
}
|
||||
|
||||
const { sessions } = await res.json();
|
||||
const activeSession = sessions.find((session: any) =>
|
||||
session.updatedAt && !session.isFinished
|
||||
);
|
||||
|
||||
if (!activeSession || !isPlayingNow(activeSession)) return null;
|
||||
|
||||
const { mediaMetadata: media, mediaType, duration, currentTime, libraryItemId } = activeSession;
|
||||
if (!media) return null;
|
||||
console.log(media);
|
||||
return {
|
||||
name: media.title || "Unknown",
|
||||
type: mediaType || "book",
|
||||
author: media.author || media.publisher,
|
||||
series: media.series[0]?.name,
|
||||
duration,
|
||||
currentTime,
|
||||
imageUrl: libraryItemId ? `${baseUrl}/api/items/${libraryItemId}/cover` : undefined,
|
||||
isFinished: activeSession.isFinished || false,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.error("Failed to query AudioBookShelf API", e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async updatePresence() {
|
||||
setActivity(await this.getActivity());
|
||||
},
|
||||
|
||||
async getActivity(): Promise<Activity | null> {
|
||||
const mediaData = await this.fetchMediaData();
|
||||
if (!mediaData || mediaData.isFinished) return null;
|
||||
|
||||
const largeImage = mediaData.imageUrl;
|
||||
console.log("Large Image URL:", largeImage);
|
||||
const assets: ActivityAssets = {
|
||||
large_image: largeImage ? await getApplicationAsset(largeImage) : await getApplicationAsset("audiobookshelf"),
|
||||
large_text: mediaData.series || mediaData.author || undefined,
|
||||
};
|
||||
|
||||
const getDetails = () => {
|
||||
return mediaData.name;
|
||||
};
|
||||
|
||||
const getState = () => {
|
||||
if (mediaData.series && mediaData.author) {
|
||||
return `${mediaData.series} • ${mediaData.author}`;
|
||||
}
|
||||
return mediaData.author || "AudioBook";
|
||||
};
|
||||
|
||||
const timestamps = mediaData.currentTime && mediaData.duration ? {
|
||||
start: Date.now() - (mediaData.currentTime * 1000),
|
||||
end: Date.now() + ((mediaData.duration - mediaData.currentTime) * 1000)
|
||||
} : undefined;
|
||||
|
||||
return {
|
||||
application_id: applicationId,
|
||||
name: "AudioBookShelf",
|
||||
|
||||
details: getDetails(),
|
||||
state: getState(),
|
||||
assets,
|
||||
timestamps,
|
||||
|
||||
type: 2,
|
||||
flags: 1,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { OptionType, PluginNative, ReporterTestable } from "@utils/types";
|
||||
import { Activity } from "@vencord/discord-types";
|
||||
import { ActivityFlags, ActivityType } from "@vencord/discord-types/enums";
|
||||
import { ApplicationAssetUtils, FluxDispatcher } from "@webpack/common";
|
||||
|
||||
const Native = VencordNative.pluginHelpers.GensokyoRadioRPC as PluginNative<typeof import("./native")>;
|
||||
|
||||
const applicationId = "1253772057926303804";
|
||||
|
||||
function setActivity(activity: Activity | null) {
|
||||
FluxDispatcher.dispatch({
|
||||
type: "LOCAL_ACTIVITY_UPDATE",
|
||||
activity,
|
||||
socketId: "GensokyoRadio",
|
||||
});
|
||||
}
|
||||
|
||||
function getImageAsset(data: string) {
|
||||
return ApplicationAssetUtils.fetchAssetIds(applicationId, [data]).then(ids => ids[0]);
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
refreshInterval: {
|
||||
type: OptionType.SLIDER,
|
||||
description: "The interval between activity refreshes (seconds)",
|
||||
markers: [1, 2, 2.5, 3, 5, 10, 15],
|
||||
default: 15,
|
||||
restartNeeded: true,
|
||||
}
|
||||
});
|
||||
|
||||
let updateInterval: NodeJS.Timeout | undefined;
|
||||
|
||||
export default definePlugin({
|
||||
name: "GensokyoRadioRPC",
|
||||
description: "Discord rich presence for Gensokyo Radio!",
|
||||
authors: [Devs.RyanCaoDev, EquicordDevs.Prince527],
|
||||
reporterTestable: ReporterTestable.None,
|
||||
|
||||
settingsAboutComponent() {
|
||||
return (
|
||||
<Paragraph>
|
||||
Discord rich presence for Gensokyo Radio!
|
||||
</Paragraph>
|
||||
);
|
||||
},
|
||||
|
||||
settings,
|
||||
|
||||
start() {
|
||||
this.updatePresence();
|
||||
updateInterval = setInterval(() => { this.updatePresence(); }, settings.store.refreshInterval * 1000);
|
||||
},
|
||||
|
||||
stop() {
|
||||
clearInterval(updateInterval);
|
||||
updateInterval = undefined;
|
||||
FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null });
|
||||
},
|
||||
|
||||
updatePresence() {
|
||||
this.getActivity().then(activity => { setActivity(activity); });
|
||||
},
|
||||
|
||||
async getActivity(): Promise<Activity | null> {
|
||||
const trackData = await Native.fetchTrackData();
|
||||
if (!trackData) return null;
|
||||
|
||||
return {
|
||||
application_id: applicationId,
|
||||
|
||||
name: "Gensokyo Radio",
|
||||
details: trackData.title,
|
||||
state: trackData.artist,
|
||||
|
||||
timestamps: {
|
||||
// start: Date.now() - (trackData.position * 1000),
|
||||
start: trackData.position * 1000,
|
||||
// end: Date.now() - (trackData.position * 1000) + (trackData.duration * 1000),
|
||||
end: trackData.duration * 1000,
|
||||
},
|
||||
|
||||
assets: {
|
||||
large_image: await getImageAsset(trackData.artwork),
|
||||
large_text: trackData.album,
|
||||
small_image: await getImageAsset("logo"),
|
||||
small_text: "Gensokyo Radio"
|
||||
},
|
||||
|
||||
buttons: undefined,
|
||||
metadata: { button_urls: undefined },
|
||||
|
||||
type: ActivityType.LISTENING,
|
||||
flags: ActivityFlags.INSTANCE,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export interface ActivityAssets {
|
||||
large_image?: string;
|
||||
large_text?: string;
|
||||
small_image?: string;
|
||||
small_text?: string;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
state: string;
|
||||
details?: string;
|
||||
timestamps?: {
|
||||
start?: number;
|
||||
end?: number;
|
||||
};
|
||||
assets?: ActivityAssets;
|
||||
buttons?: Array<string>;
|
||||
name: string;
|
||||
application_id: string;
|
||||
metadata?: {
|
||||
button_urls?: Array<string>;
|
||||
};
|
||||
type: number;
|
||||
flags: number;
|
||||
}
|
||||
|
||||
export interface ActivityAssets {
|
||||
large_image?: string;
|
||||
large_text?: string;
|
||||
small_image?: string;
|
||||
small_text?: string;
|
||||
}
|
||||
|
||||
export interface TrackData {
|
||||
title: string;
|
||||
album: string;
|
||||
artist: string;
|
||||
|
||||
artwork: string;
|
||||
|
||||
position: number;
|
||||
duration: number;
|
||||
}
|
||||
|
|
@ -1,419 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
// alot of the code is from LastFMRichPresence
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { HeadingSecondary } from "@components/Heading";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { ApplicationAssetUtils, FluxDispatcher, showToast } from "@webpack/common";
|
||||
|
||||
interface ActivityAssets {
|
||||
large_image?: string;
|
||||
large_text?: string;
|
||||
small_image?: string;
|
||||
small_text?: string;
|
||||
}
|
||||
|
||||
interface ActivityButton {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Activity {
|
||||
state: string;
|
||||
details?: string;
|
||||
timestamps?: {
|
||||
start?: number;
|
||||
};
|
||||
assets?: ActivityAssets;
|
||||
buttons?: Array<string>;
|
||||
name: string;
|
||||
application_id: string;
|
||||
metadata?: {
|
||||
button_urls?: Array<string>;
|
||||
};
|
||||
type: number;
|
||||
flags: number;
|
||||
}
|
||||
|
||||
interface MediaData {
|
||||
name: string;
|
||||
type: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
seriesName?: string;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
year?: number;
|
||||
url?: string;
|
||||
imageUrl?: string;
|
||||
duration?: number;
|
||||
position?: number;
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
serverUrl: {
|
||||
description: "Jellyfin server URL (e.g., https://jellyfin.example.com)",
|
||||
type: OptionType.STRING,
|
||||
},
|
||||
apiKey: {
|
||||
description: "Jellyfin API key obtained from your Jellyfin administration dashboard",
|
||||
type: OptionType.STRING,
|
||||
},
|
||||
userId: {
|
||||
description: "Jellyfin user ID obtained from your user profile URL",
|
||||
type: OptionType.STRING,
|
||||
},
|
||||
nameDisplay: {
|
||||
description: "Choose how the application name should appear in Rich Presence",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: "Series/Movie Name", value: "default", default: true },
|
||||
{ label: "Series - Episode/Track/Movie Name", value: "full" },
|
||||
{ label: "Custom", value: "custom" },
|
||||
],
|
||||
},
|
||||
customName: {
|
||||
description: "Custom Rich Presence name (only used if 'Custom' is selected).\nOptions: {name}, {series}, {season}, {episode}, {artist}, {album}, {year}",
|
||||
type: OptionType.STRING,
|
||||
},
|
||||
coverType: {
|
||||
description: "Choose which cover to display when watching a TV show",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: "Series Cover", value: "series", default: true },
|
||||
{ label: "Episode Cover", value: "episode" },
|
||||
],
|
||||
},
|
||||
episodeFormat: {
|
||||
description: "Episode number format",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: "S01E01", value: "long", default: true },
|
||||
{ label: "1x01", value: "short" },
|
||||
{ label: "Season 1 Episode 1", value: "fulltext" },
|
||||
],
|
||||
},
|
||||
showEpisodeName: {
|
||||
description: "Show episode name after season/episode info",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
},
|
||||
overrideRichPresenceType: {
|
||||
description: "Override the rich presence type",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{
|
||||
label: "Off",
|
||||
value: false,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
label: "Listening",
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
label: "Playing",
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
label: "Streaming",
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: "Watching",
|
||||
value: 3
|
||||
},
|
||||
],
|
||||
},
|
||||
showPausedState: {
|
||||
description: "Show Rich Presence when media is paused",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
privacyMode: {
|
||||
description: "Privacy Mode (Hide media details like Episode/Song Name)",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const applicationId = "1381368130164625469";
|
||||
|
||||
const logger = new Logger("JellyfinRichPresence");
|
||||
|
||||
let updateInterval: NodeJS.Timeout | undefined;
|
||||
|
||||
async function getApplicationAsset(key: string): Promise<string> {
|
||||
return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0];
|
||||
}
|
||||
|
||||
function setActivity(activity: Activity | null) {
|
||||
FluxDispatcher.dispatch({
|
||||
type: "LOCAL_ACTIVITY_UPDATE",
|
||||
activity,
|
||||
socketId: "Jellyfin",
|
||||
});
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "JellyfinRichPresence",
|
||||
description: "Rich presence for Jellyfin media server",
|
||||
authors: [EquicordDevs.vmohammad, Devs.SerStars, EquicordDevs.ZcraftElite],
|
||||
|
||||
settingsAboutComponent: () => (
|
||||
<>
|
||||
<HeadingSecondary>How to get an API key</HeadingSecondary>
|
||||
<Paragraph>
|
||||
Auth token can be found by following these steps:
|
||||
<ol style={{ marginTop: 8, marginBottom: 8, paddingLeft: 20 }}>
|
||||
<li>1. Log into your Jellyfin instance</li>
|
||||
<li>2. Open your browser's Developer Tools (usually F12 or right-click then Inspect)</li>
|
||||
<li>3. Go to the <b>Network</b> tab in Developer Tools</li>
|
||||
<li>4. Look for requests to your Jellyfin server</li>
|
||||
<li>
|
||||
5. In the request headers, find <code>X-MediaBrowser-Token</code> or <code>Authorization</code>
|
||||
<br />
|
||||
<i>
|
||||
Easiest way: press <b>Ctrl+F</b> in the Developer Tools and search for <code>X-MediaBrowser-Token</code>
|
||||
</i>
|
||||
</li>
|
||||
</ol>
|
||||
<br />
|
||||
You'll also need your User ID, which can be found in the url of your user profile page.
|
||||
</Paragraph>
|
||||
</>
|
||||
),
|
||||
|
||||
settings,
|
||||
|
||||
start() {
|
||||
this.updatePresence();
|
||||
updateInterval = setInterval(() => { this.updatePresence(); }, 10000);
|
||||
},
|
||||
|
||||
stop() {
|
||||
clearInterval(updateInterval);
|
||||
updateInterval = undefined;
|
||||
},
|
||||
|
||||
async fetchMediaData(): Promise<MediaData | null> {
|
||||
if (!settings.store.serverUrl || !settings.store.apiKey || !settings.store.userId) {
|
||||
logger.warn("Jellyfin server URL, API key, or user ID is not set in settings.");
|
||||
showToast("JellyfinRPC is not configured.", "failure", {
|
||||
duration: 15000,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = settings.store.serverUrl.replace(/\/$/, "");
|
||||
const url = `${baseUrl}/Sessions?api_key=${settings.store.apiKey}`;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw `${res.status} ${res.statusText}`;
|
||||
|
||||
const sessions = await res.json();
|
||||
const userSession = sessions.find((session: any) =>
|
||||
session.UserId === settings.store.userId && session.NowPlayingItem
|
||||
);
|
||||
|
||||
if (!userSession || !userSession.NowPlayingItem) return null;
|
||||
|
||||
const item = userSession.NowPlayingItem;
|
||||
const playState = userSession.PlayState;
|
||||
|
||||
if (playState?.IsPaused && !settings.store.showPausedState) return null;
|
||||
|
||||
const imageUrl = item.ImageTags?.Primary
|
||||
? `${baseUrl}/Items/${item.Type === "Episode" &&
|
||||
item.SeriesId &&
|
||||
settings.store.coverType === "series"
|
||||
? item.SeriesId
|
||||
: item.Id
|
||||
}/Images/Primary`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
name: item.Name || "Unknown",
|
||||
type: item.Type,
|
||||
artist: item.Artists?.[0] || item.AlbumArtist,
|
||||
album: item.Album,
|
||||
seriesName: item.SeriesName,
|
||||
seasonNumber: item.ParentIndexNumber,
|
||||
episodeNumber: item.IndexNumber,
|
||||
year: item.ProductionYear,
|
||||
url: `${baseUrl}/web/#!/details?id=${item.Id}`,
|
||||
imageUrl,
|
||||
duration: item.RunTimeTicks ? Math.floor(item.RunTimeTicks / 10000000) : undefined,
|
||||
position: playState?.PositionTicks ? Math.floor(playState.PositionTicks / 10000000) : undefined,
|
||||
isPaused: !!playState?.IsPaused,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.error("Failed to query Jellyfin API", e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async updatePresence() {
|
||||
setActivity(await this.getActivity());
|
||||
},
|
||||
|
||||
async getActivity(): Promise<Activity | null> {
|
||||
let richPresenceType;
|
||||
let appName: string;
|
||||
const nameSetting = settings.store.nameDisplay || "default";
|
||||
|
||||
const mediaData = await this.fetchMediaData();
|
||||
if (!mediaData) return null;
|
||||
|
||||
if (settings.store.overrideRichPresenceType) {
|
||||
richPresenceType = settings.store.overrideRichPresenceType;
|
||||
} else {
|
||||
switch (mediaData.type) {
|
||||
case "Audio":
|
||||
richPresenceType = 2;
|
||||
break;
|
||||
default:
|
||||
richPresenceType = 3;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const templateReplace = (template: string) => {
|
||||
return template
|
||||
.replace(/\{name\}/g, mediaData.name || "")
|
||||
.replace(/\{series\}/g, mediaData.seriesName || "")
|
||||
.replace(/\{season\}/g, mediaData.seasonNumber?.toString() || "")
|
||||
.replace(/\{episode\}/g, mediaData.episodeNumber?.toString() || "")
|
||||
.replace(/\{artist\}/g, mediaData.artist || "")
|
||||
.replace(/\{album\}/g, mediaData.album || "")
|
||||
.replace(/\{year\}/g, mediaData.year?.toString() || "");
|
||||
};
|
||||
|
||||
switch (nameSetting) {
|
||||
case "full":
|
||||
if (mediaData.type === "Episode" && mediaData.seriesName) {
|
||||
appName = settings.store.privacyMode
|
||||
? `${mediaData.seriesName} - [Episode Hidden]`
|
||||
: `${mediaData.seriesName} - ${mediaData.name}`;
|
||||
} else if (mediaData.type === "Audio") {
|
||||
appName = settings.store.privacyMode
|
||||
? "[Track Hidden]"
|
||||
: `${mediaData.artist || "Unknown Artist"} - ${mediaData.name}`;
|
||||
} else {
|
||||
appName = settings.store.privacyMode
|
||||
? "[Movie Hidden]"
|
||||
: mediaData.name || "Jellyfin";
|
||||
}
|
||||
break;
|
||||
case "custom":
|
||||
appName = templateReplace(settings.store.customName || "{name} on Jellyfin");
|
||||
if (settings.store.privacyMode) {
|
||||
appName = appName
|
||||
.replace(mediaData.name || "", "[Title Hidden]")
|
||||
.replace(mediaData.seriesName || "", "[Series Hidden]")
|
||||
.replace(mediaData.artist || "", "[Artist Hidden]")
|
||||
.replace(mediaData.album || "", "[Album Hidden]");
|
||||
}
|
||||
break;
|
||||
case "default":
|
||||
default:
|
||||
if (mediaData.type === "Episode" && mediaData.seriesName) {
|
||||
appName = mediaData.seriesName;
|
||||
} else {
|
||||
appName = settings.store.privacyMode
|
||||
? "[Media Hidden]"
|
||||
: mediaData.name || "Jellyfin";
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const assets: ActivityAssets = {
|
||||
large_image: (mediaData.imageUrl
|
||||
? await getApplicationAsset(mediaData.imageUrl)
|
||||
: undefined),
|
||||
large_text: mediaData.seriesName || mediaData.album || undefined,
|
||||
};
|
||||
|
||||
if (settings.store.privacyMode) {
|
||||
assets.large_image = undefined;
|
||||
}
|
||||
|
||||
const buttons: ActivityButton[] = [];
|
||||
|
||||
const getDetails = () => {
|
||||
if (mediaData.type === "Episode" && mediaData.seriesName) {
|
||||
return settings.store.privacyMode ? "Watching a TV Show" : mediaData.seriesName;
|
||||
}
|
||||
return settings.store.privacyMode ? "Watching Something" : mediaData.name;
|
||||
};
|
||||
|
||||
const getState = () => {
|
||||
if (mediaData.isPaused) {
|
||||
return "Paused";
|
||||
}
|
||||
if (mediaData.type === "Episode" && mediaData.seriesName) {
|
||||
let episodeFormat = "";
|
||||
const season = mediaData.seasonNumber;
|
||||
const episode = mediaData.episodeNumber;
|
||||
const format = settings.store.episodeFormat || "long";
|
||||
|
||||
if (season != null && episode != null) {
|
||||
switch (format) {
|
||||
case "long":
|
||||
episodeFormat = `S${season.toString().padStart(2, "0")}E${episode.toString().padStart(2, "0")}`;
|
||||
break;
|
||||
case "short":
|
||||
episodeFormat = `${season}x${episode.toString().padStart(2, "0")}`;
|
||||
break;
|
||||
case "fulltext":
|
||||
episodeFormat = `Season ${season} Episode ${episode}`;
|
||||
break;
|
||||
}
|
||||
} else if (season != null) {
|
||||
episodeFormat = format === "fulltext" ? `Season ${season}` : `S${season.toString().padStart(2, "0")}`;
|
||||
} else if (episode != null) {
|
||||
episodeFormat = format === "fulltext" ? `Episode ${episode}` : `E${episode.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
if (settings.store.showEpisodeName && mediaData.name && !settings.store.privacyMode) {
|
||||
return `${episodeFormat} - ${mediaData.name}`;
|
||||
}
|
||||
return episodeFormat;
|
||||
}
|
||||
if (settings.store.privacyMode) {
|
||||
return mediaData.type === "Audio" ? "Listening to music" : (mediaData.year ? "(????)" : undefined);
|
||||
}
|
||||
return mediaData.artist || (mediaData.year ? `(${mediaData.year})` : undefined);
|
||||
};
|
||||
|
||||
const timestamps = (!mediaData.isPaused && mediaData.position != null && mediaData.duration != null) ? {
|
||||
start: Date.now() - (mediaData.position * 1000),
|
||||
end: Date.now() + ((mediaData.duration - mediaData.position) * 1000)
|
||||
} : undefined;
|
||||
|
||||
return {
|
||||
application_id: applicationId,
|
||||
name: appName,
|
||||
details: getDetails(),
|
||||
state: getState() || "something",
|
||||
assets,
|
||||
timestamps,
|
||||
|
||||
buttons: buttons.length ? buttons.map(v => v.label) : undefined,
|
||||
metadata: {
|
||||
button_urls: buttons.map(v => v.url),
|
||||
},
|
||||
type: richPresenceType,
|
||||
flags: 1
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
@ -1,376 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { HeadingSecondary } from "@components/Heading";
|
||||
import { Link } from "@components/Link";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Activity, ActivityAssets, ActivityButton } from "@vencord/discord-types";
|
||||
import { ActivityFlags, ActivityType } from "@vencord/discord-types/enums";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { ApplicationAssetUtils, FluxDispatcher } from "@webpack/common";
|
||||
|
||||
interface TrackData {
|
||||
name: string;
|
||||
album: string;
|
||||
artist: string;
|
||||
durationMs?: number;
|
||||
recordingMBID?: string;
|
||||
url: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
const enum NameFormat {
|
||||
StatusName = "status-name",
|
||||
ArtistFirst = "artist-first",
|
||||
SongFirst = "song-first",
|
||||
ArtistOnly = "artist",
|
||||
SongOnly = "song",
|
||||
AlbumName = "album",
|
||||
}
|
||||
|
||||
const applicationId = "1090155131007406132";
|
||||
const placeholderId = "2a96cbd8b46e442fc41c2b86b821562f";
|
||||
|
||||
const logger = new Logger("ListenBrainzRPC");
|
||||
|
||||
const PresenceStore = findByPropsLazy("getLocalPresence");
|
||||
|
||||
let updateInterval: NodeJS.Timeout | undefined;
|
||||
|
||||
async function getApplicationAsset(key: string): Promise<string> {
|
||||
return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0];
|
||||
}
|
||||
|
||||
function setActivity(activity: Activity | null) {
|
||||
FluxDispatcher.dispatch({
|
||||
type: "LOCAL_ACTIVITY_UPDATE",
|
||||
activity,
|
||||
socketId: "ListenBrainz",
|
||||
});
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
username: {
|
||||
description: "ListenBrainz username",
|
||||
type: OptionType.STRING,
|
||||
},
|
||||
mbContact: {
|
||||
description: "ListenBrainz contact",
|
||||
type: OptionType.STRING,
|
||||
},
|
||||
shareUsername: {
|
||||
description:
|
||||
"show link to ListenBrainz profile (may only be visible to other users)",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
},
|
||||
shareSong: {
|
||||
description:
|
||||
"show link to song on ListenBrainz (may only be visible to other users)",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
hideWithSpotify: {
|
||||
description: "hide ListenBrainz presence if Spotify is running",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
hideWithActivity: {
|
||||
description: "hide ListenBrainz presence if you have any other presence",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
},
|
||||
useTimeBar: {
|
||||
description:
|
||||
"use track duration to display a time bar (must be using Listening status)",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
statusName: {
|
||||
description: "custom status text",
|
||||
type: OptionType.STRING,
|
||||
default: "some music",
|
||||
},
|
||||
nameFormat: {
|
||||
description: "Show name of song and artist in status name",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{
|
||||
label: "Use custom status name",
|
||||
value: NameFormat.StatusName,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
label: "Use format 'artist - song'",
|
||||
value: NameFormat.ArtistFirst,
|
||||
},
|
||||
{
|
||||
label: "Use format 'song - artist'",
|
||||
value: NameFormat.SongFirst,
|
||||
},
|
||||
{
|
||||
label: "Use artist name only",
|
||||
value: NameFormat.ArtistOnly,
|
||||
},
|
||||
{
|
||||
label: "Use song name only",
|
||||
value: NameFormat.SongOnly,
|
||||
},
|
||||
{
|
||||
label:
|
||||
"Use album name (falls back to custom status text if song has no album)",
|
||||
value: NameFormat.AlbumName,
|
||||
},
|
||||
],
|
||||
},
|
||||
useListeningStatus: {
|
||||
description: 'show "Listening to" status instead of "Playing"',
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
missingArt: {
|
||||
description: "When album or album art is missing",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{
|
||||
label: "Use large ListenBrainz logo",
|
||||
value: "listenbrainzLogo",
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
label: "Use generic placeholder",
|
||||
value: "placeholder",
|
||||
},
|
||||
],
|
||||
},
|
||||
useLogo: {
|
||||
description: "Show ListenBrainz logo on album art",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
var currentRecordingMBID = "";
|
||||
var currentStart = 0;
|
||||
|
||||
export default definePlugin({
|
||||
name: "ListenBrainzRPC",
|
||||
description: "Little plugin for ListenBrainz rich presence",
|
||||
authors: [EquicordDevs.qouesm],
|
||||
|
||||
settingsAboutComponent: () => (
|
||||
<>
|
||||
<HeadingSecondary>About MusicBrainz API</HeadingSecondary>
|
||||
<Paragraph>
|
||||
The MusicBrainz API does not require an API key, but it does require a{" "}
|
||||
<Link href="https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting#Provide_meaningful_User-Agent_strings">
|
||||
{" "}
|
||||
meaningful user-agent string
|
||||
</Link>{" "}
|
||||
. For most, an email address should suffice.
|
||||
</Paragraph>
|
||||
</>
|
||||
),
|
||||
|
||||
settings,
|
||||
|
||||
start() {
|
||||
this.updatePresence();
|
||||
updateInterval = setInterval(() => {
|
||||
this.updatePresence();
|
||||
}, 16000);
|
||||
},
|
||||
|
||||
stop() {
|
||||
clearInterval(updateInterval);
|
||||
updateInterval = undefined;
|
||||
},
|
||||
|
||||
async fetchTrackData(): Promise<TrackData | null> {
|
||||
if (!settings.store.username) return null;
|
||||
|
||||
try {
|
||||
const lbRes = await fetch(
|
||||
`https://api.listenbrainz.org/1/user/${settings.store.username}/playing-now`,
|
||||
);
|
||||
if (!lbRes.ok) throw `${lbRes.status} ${lbRes.statusText}`;
|
||||
|
||||
const lbJson = await lbRes.json();
|
||||
if (lbJson.error) {
|
||||
logger.error(
|
||||
"Error from ListenBrainz API",
|
||||
`${lbJson.error}: ${lbJson.message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const listen = lbJson.payload?.listens?.[0];
|
||||
if (!listen?.playing_now) return null;
|
||||
|
||||
const trackMetadata = listen.track_metadata;
|
||||
const albumName = trackMetadata.release_name || "Unknown";
|
||||
const artistName = trackMetadata.artist_name || "Unknown";
|
||||
|
||||
const mbRes = await fetch(`https://musicbrainz.org/ws/2/release/?query=release:${encodeURIComponent(albumName)}%20AND%20artist:${encodeURIComponent(artistName)}&fmt=json`,);
|
||||
|
||||
if (!mbRes.ok) throw `${mbRes.status} ${mbRes.statusText}`;
|
||||
|
||||
const mbJson = await mbRes.json();
|
||||
const releases = mbJson.releases || [];
|
||||
|
||||
const releaseGroup = releases[0]["release-group"].id;
|
||||
|
||||
const caaRes = await fetch(
|
||||
`https://coverartarchive.org/release-group/${releaseGroup}`,
|
||||
);
|
||||
if (!caaRes.ok) throw `${caaRes.status} ${caaRes.statusText}`;
|
||||
const caaJson = await caaRes.json();
|
||||
|
||||
const url: string = caaJson.release;
|
||||
|
||||
const { images } = caaJson;
|
||||
let imageUrl: string = "";
|
||||
for (const image of images) {
|
||||
imageUrl = image.thumbnails.large || "";
|
||||
if (!imageUrl) continue;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
name: trackMetadata.track_name || "Unknown",
|
||||
album: albumName,
|
||||
artist: artistName,
|
||||
durationMs: trackMetadata.additional_info.duration_ms,
|
||||
recordingMBID: trackMetadata.additional_info.recording_mbid,
|
||||
url: url,
|
||||
imageUrl: imageUrl,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.error("Failed to query ListenBrainz API", e);
|
||||
// will clear the rich presence if API fails
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async updatePresence() {
|
||||
setActivity(await this.getActivity());
|
||||
},
|
||||
|
||||
getLargeImage(track: TrackData): string | undefined {
|
||||
if (track.imageUrl && !track.imageUrl.includes(placeholderId))
|
||||
return track.imageUrl;
|
||||
|
||||
if (settings.store.missingArt === "placeholder") return "placeholder";
|
||||
},
|
||||
|
||||
async getActivity(): Promise<Activity | null> {
|
||||
if (settings.store.hideWithActivity) {
|
||||
if (
|
||||
PresenceStore.getActivities().some(
|
||||
a => a.application_id !== applicationId,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.store.hideWithSpotify) {
|
||||
if (
|
||||
PresenceStore.getActivities().some(
|
||||
a =>
|
||||
a.type === ActivityType.LISTENING &&
|
||||
a.application_id !== applicationId,
|
||||
)
|
||||
) {
|
||||
// there is already music status because of Spotify or richerCider (probably more)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const trackData = await this.fetchTrackData();
|
||||
if (!trackData) return null;
|
||||
|
||||
const largeImage = this.getLargeImage(trackData);
|
||||
const assets: ActivityAssets = largeImage
|
||||
? {
|
||||
large_image: await getApplicationAsset(largeImage),
|
||||
large_text: trackData.album || undefined,
|
||||
small_image: settings.store.useLogo
|
||||
? await getApplicationAsset("listenbrainz")
|
||||
: undefined,
|
||||
small_text: "ListenBrainz",
|
||||
}
|
||||
: {
|
||||
large_image: await getApplicationAsset("listenbrainz"),
|
||||
large_text: trackData.album || undefined,
|
||||
};
|
||||
|
||||
const buttons: ActivityButton[] = [];
|
||||
|
||||
if (settings.store.shareUsername)
|
||||
buttons.push({
|
||||
label: "ListenBrainz Profile",
|
||||
url: `https://www.listenbrainz.org/user/${settings.store.username}`,
|
||||
});
|
||||
|
||||
if (settings.store.shareSong)
|
||||
buttons.push({
|
||||
label: "View Song",
|
||||
url: trackData.url,
|
||||
});
|
||||
|
||||
const statusName = (() => {
|
||||
switch (settings.store.nameFormat) {
|
||||
case NameFormat.ArtistFirst:
|
||||
return trackData.artist + " - " + trackData.name;
|
||||
case NameFormat.SongFirst:
|
||||
return trackData.name + " - " + trackData.artist;
|
||||
case NameFormat.ArtistOnly:
|
||||
return trackData.artist;
|
||||
case NameFormat.SongOnly:
|
||||
return trackData.name;
|
||||
case NameFormat.AlbumName:
|
||||
return trackData.album || settings.store.statusName;
|
||||
default:
|
||||
return settings.store.statusName;
|
||||
}
|
||||
})();
|
||||
|
||||
if (trackData.recordingMBID && trackData.recordingMBID !== currentRecordingMBID) {
|
||||
currentRecordingMBID = trackData.recordingMBID;
|
||||
currentStart = Date.now();
|
||||
}
|
||||
|
||||
return {
|
||||
application_id: applicationId,
|
||||
name: statusName,
|
||||
|
||||
details: trackData.name,
|
||||
state: trackData.artist,
|
||||
assets,
|
||||
|
||||
timestamps: {
|
||||
start: settings.store.useTimeBar ? currentStart : undefined,
|
||||
end: settings.store.useTimeBar
|
||||
? currentStart + (trackData.durationMs ?? 0)
|
||||
: undefined,
|
||||
},
|
||||
buttons: buttons.length ? buttons.map(v => v.label) : undefined,
|
||||
metadata: {
|
||||
button_urls: buttons.map(v => v.url),
|
||||
},
|
||||
|
||||
type: settings.store.useListeningStatus
|
||||
? ActivityType.LISTENING
|
||||
: ActivityType.PLAYING,
|
||||
flags: ActivityFlags.INSTANCE,
|
||||
};
|
||||
},
|
||||
});
|
||||
233
src/equicordplugins/richPresence/SettingsPanel.tsx
Normal file
233
src/equicordplugins/richPresence/SettingsPanel.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { SettingsSection } from "@components/settings/tabs/plugins/components/Common";
|
||||
import { Switch } from "@components/Switch";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { classes } from "@utils/misc";
|
||||
import { Select, Slider, TabBar, TextInput, useState } from "@webpack/common";
|
||||
|
||||
import { onServiceChange, settings, SettingsStore } from "./settings";
|
||||
import { NameFormat, ServiceTab } from "./types";
|
||||
|
||||
type SettingsKey = keyof SettingsStore;
|
||||
|
||||
function SwitchSetting({ name, description, settingsKey }: { name: string; description: string; settingsKey: SettingsKey; }) {
|
||||
const [value, setValue] = useState(settings.store[settingsKey] ?? false);
|
||||
return (
|
||||
<SettingsSection tag="label" name={name} description={description} inlineSetting>
|
||||
<Switch checked={!!value} onChange={v => { setValue(v); (settings.store[settingsKey] as boolean) = v; onServiceChange?.(); }} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function TextSetting({ name, description, settingsKey, placeholder }: { name: string; description: string; settingsKey: SettingsKey; placeholder?: string; }) {
|
||||
const [value, setValue] = useState(settings.store[settingsKey] ?? "");
|
||||
return (
|
||||
<SettingsSection name={name} description={description}>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={String(value)}
|
||||
onChange={v => { setValue(v); (settings.store[settingsKey] as string) = v; }}
|
||||
placeholder={placeholder ?? "Enter a value"}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSetting({ name, description, settingsKey, options }: { name: string; description: string; settingsKey: SettingsKey; options: { label: string; value: string; }[]; }) {
|
||||
const [value, setValue] = useState(settings.store[settingsKey] ?? options[0]?.value);
|
||||
return (
|
||||
<SettingsSection name={name} description={description}>
|
||||
<Select
|
||||
options={options}
|
||||
isSelected={v => v === value}
|
||||
select={v => { setValue(v); (settings.store[settingsKey] as string) = v; }}
|
||||
serialize={String}
|
||||
closeOnSelect
|
||||
maxVisibleItems={5}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioBookShelfSettings() {
|
||||
return (
|
||||
<>
|
||||
<SettingsSection name="" description="Display your currently playing audiobooks as Discord Rich Presence. Requires your AudioBookShelf server URL, username, and password." />
|
||||
<TextSetting name="Server URL" description="AudioBookShelf server URL." settingsKey="abs_serverUrl" placeholder="https://abs.example.com" />
|
||||
<TextSetting name="Username" description="AudioBookShelf username." settingsKey="abs_username" />
|
||||
<TextSetting name="Password" description="AudioBookShelf password." settingsKey="abs_password" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TosuSettings() {
|
||||
return (
|
||||
<SettingsSection name="" description="Connects to tosu via WebSocket on port 24050. No configuration needed, just make sure tosu is running alongside osu!." />
|
||||
);
|
||||
}
|
||||
|
||||
const nameFormatOptions = [
|
||||
{ label: "Use custom status name", value: NameFormat.StatusName },
|
||||
{ label: "Use format 'artist - song'", value: NameFormat.ArtistFirst },
|
||||
{ label: "Use format 'song - artist'", value: NameFormat.SongFirst },
|
||||
{ label: "Use artist name only", value: NameFormat.ArtistOnly },
|
||||
{ label: "Use song name only", value: NameFormat.SongOnly },
|
||||
{ label: "Use album name", value: NameFormat.AlbumName },
|
||||
];
|
||||
|
||||
function StatsFmSettings() {
|
||||
return (
|
||||
<>
|
||||
<SettingsSection name="" description="Show what you're currently listening to via stats.fm. Requires your listening history to be public." />
|
||||
<TextSetting name="Username" description="Stats.fm username." settingsKey="sfm_username" placeholder="stats.fm username" />
|
||||
<TextSetting name="Custom Status Text" description="Custom status text." settingsKey="sfm_statusName" placeholder="Stats.fm" />
|
||||
<SelectSetting name="Name Format" description="Name format." settingsKey="sfm_nameFormat" options={nameFormatOptions} />
|
||||
<SelectSetting name="Missing Art Fallback" description="Fallback when art is missing." settingsKey="sfm_missingArt" options={[
|
||||
{ label: "Use large Stats.fm logo", value: "StatsFmLogo" },
|
||||
{ label: "Use generic placeholder", value: "placeholder" },
|
||||
]} />
|
||||
<SwitchSetting name="Show Listening Status" description="Show listening status." settingsKey="sfm_useListeningStatus" />
|
||||
<SwitchSetting name="Show Stats.fm Logo" description="Show Stats.fm logo next to album art." settingsKey="sfm_showLogo" />
|
||||
<SwitchSetting name="Show Profile Link" description="Show link to stats.fm profile." settingsKey="sfm_shareUsername" />
|
||||
<SwitchSetting name="Show Song Link" description="Show link to song on stats.fm." settingsKey="sfm_shareSong" />
|
||||
<SwitchSetting name="Hide With Spotify" description="Hide stats.fm presence if Spotify is running." settingsKey="sfm_hideWithSpotify" />
|
||||
<SwitchSetting name="Hide With External RPC" description="Hide stats.fm presence if an external RPC is running." settingsKey="sfm_hideWithExternalRPC" />
|
||||
<SwitchSetting name="Disable Album Art" description="Disable downloading album art." settingsKey="sfm_alwaysHideArt" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function JellyfinSettings() {
|
||||
return (
|
||||
<>
|
||||
<SettingsSection name="" description="Show what you're playing on Jellyfin. To get your API key: open your Jellyfin web UI, press F12 to open Developer Tools, go to the Network tab, look for requests to your server, and find the X-MediaBrowser-Token header (Ctrl+F to search). Your user ID can be found in your profile page URL." />
|
||||
<TextSetting name="Server URL" description="Jellyfin server URL." settingsKey="jf_serverUrl" placeholder="https://jellyfin.example.com" />
|
||||
<TextSetting name="API Key" description="Jellyfin API key." settingsKey="jf_apiKey" placeholder="X-MediaBrowser-Token" />
|
||||
<TextSetting name="User ID" description="Jellyfin user ID." settingsKey="jf_userId" placeholder="User ID from profile URL" />
|
||||
<SelectSetting name="Name Display" description="Name display format." settingsKey="jf_nameDisplay" options={[
|
||||
{ label: "Series/Movie Name", value: "default" },
|
||||
{ label: "Series - Episode/Track/Movie Name", value: "full" },
|
||||
{ label: "Custom", value: "custom" },
|
||||
]} />
|
||||
<TextSetting name="Custom Name Template" description="Custom name template." settingsKey="jf_customName" placeholder="{name} on Jellyfin" />
|
||||
<SelectSetting name="Cover Type" description="Cover type for TV shows." settingsKey="jf_coverType" options={[
|
||||
{ label: "Series Cover", value: "series" },
|
||||
{ label: "Episode Cover", value: "episode" },
|
||||
]} />
|
||||
<SelectSetting name="Episode Format" description="Episode number format." settingsKey="jf_episodeFormat" options={[
|
||||
{ label: "S01E01", value: "long" },
|
||||
{ label: "1x01", value: "short" },
|
||||
{ label: "Season 1 Episode 1", value: "fulltext" },
|
||||
]} />
|
||||
<SelectSetting name="Override Presence Type" description="Override rich presence type." settingsKey="jf_overrideType" options={[
|
||||
{ label: "Off", value: "off" },
|
||||
{ label: "Listening", value: "2" },
|
||||
{ label: "Playing", value: "0" },
|
||||
{ label: "Streaming", value: "1" },
|
||||
{ label: "Watching", value: "3" },
|
||||
]} />
|
||||
<SwitchSetting name="Show Episode Name" description="Show episode name after season/episode info." settingsKey="jf_showEpisodeName" />
|
||||
<SwitchSetting name="Show When Paused" description="Show presence when media is paused." settingsKey="jf_showPausedState" />
|
||||
<SwitchSetting name="Privacy Mode" description="Hide media details." settingsKey="jf_privacyMode" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ListenBrainzSettings() {
|
||||
return (
|
||||
<>
|
||||
<SettingsSection name="" description="Show what you're currently listening to via ListenBrainz. The MusicBrainz API requires a meaningful user agent string (an email usually works)." />
|
||||
<TextSetting name="Username" description="ListenBrainz username." settingsKey="lb_username" placeholder="ListenBrainz username" />
|
||||
<TextSetting name="MusicBrainz Contact" description="MusicBrainz contact for user agent." settingsKey="lb_mbContact" placeholder="your@email.com" />
|
||||
<TextSetting name="Custom Status Text" description="Custom status text." settingsKey="lb_statusName" placeholder="some music" />
|
||||
<SelectSetting name="Name Format" description="Name format." settingsKey="lb_nameFormat" options={nameFormatOptions} />
|
||||
<SelectSetting name="Missing Art Fallback" description="Fallback when art is missing." settingsKey="lb_missingArt" options={[
|
||||
{ label: "Use large ListenBrainz logo", value: "listenbrainzLogo" },
|
||||
{ label: "Use generic placeholder", value: "placeholder" },
|
||||
]} />
|
||||
<SwitchSetting name="Show Listening Status" description="Show listening status." settingsKey="lb_useListeningStatus" />
|
||||
<SwitchSetting name="Show Time Bar" description="Use track duration to display a time bar." settingsKey="lb_useTimeBar" />
|
||||
<SwitchSetting name="Show ListenBrainz Logo" description="Show ListenBrainz logo on album art." settingsKey="lb_useLogo" />
|
||||
<SwitchSetting name="Show Profile Link" description="Show link to ListenBrainz profile." settingsKey="lb_shareUsername" />
|
||||
<SwitchSetting name="Show Song Link" description="Show link to song on ListenBrainz." settingsKey="lb_shareSong" />
|
||||
<SwitchSetting name="Hide With Spotify" description="Hide presence if Spotify is running." settingsKey="lb_hideWithSpotify" />
|
||||
<SwitchSetting name="Hide With Any Activity" description="Hide presence if any other presence exists." settingsKey="lb_hideWithActivity" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GensokyoRadioSettings() {
|
||||
const [value, setValue] = useState(settings.store.gr_refreshInterval ?? 15);
|
||||
return (
|
||||
<>
|
||||
<SettingsSection name="" description="Discord rich presence for Gensokyo Radio. Just enable it and listen!" />
|
||||
<SettingsSection name="Refresh Interval" description="Refresh interval in seconds.">
|
||||
<Slider
|
||||
markers={[1, 2, 2.5, 3, 5, 10, 15]}
|
||||
initialValue={value}
|
||||
onValueChange={v => { setValue(v); settings.store.gr_refreshInterval = v; }}
|
||||
onValueRender={v => `${v}s`}
|
||||
stickToMarkers
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const TAB_COMPONENTS: Record<ServiceTab, React.ComponentType> = {
|
||||
[ServiceTab.AudioBookShelf]: AudioBookShelfSettings,
|
||||
[ServiceTab.Tosu]: TosuSettings,
|
||||
[ServiceTab.StatsFm]: StatsFmSettings,
|
||||
[ServiceTab.Jellyfin]: JellyfinSettings,
|
||||
[ServiceTab.ListenBrainz]: ListenBrainzSettings,
|
||||
[ServiceTab.GensokyoRadio]: GensokyoRadioSettings,
|
||||
};
|
||||
|
||||
const TAB_LABELS: Record<ServiceTab, string> = {
|
||||
[ServiceTab.AudioBookShelf]: "AudioBookShelf",
|
||||
[ServiceTab.Tosu]: "osu!",
|
||||
[ServiceTab.StatsFm]: "stats.fm",
|
||||
[ServiceTab.Jellyfin]: "Jellyfin",
|
||||
[ServiceTab.ListenBrainz]: "ListenBrainz",
|
||||
[ServiceTab.GensokyoRadio]: "Gensokyo Radio",
|
||||
};
|
||||
|
||||
const ENABLE_KEYS: Record<ServiceTab, SettingsKey> = {
|
||||
[ServiceTab.AudioBookShelf]: "abs_enabled",
|
||||
[ServiceTab.Tosu]: "tosu_enabled",
|
||||
[ServiceTab.StatsFm]: "sfm_enabled",
|
||||
[ServiceTab.Jellyfin]: "jf_enabled",
|
||||
[ServiceTab.ListenBrainz]: "lb_enabled",
|
||||
[ServiceTab.GensokyoRadio]: "gr_enabled",
|
||||
};
|
||||
|
||||
const TABS = Object.values(ServiceTab);
|
||||
|
||||
export function SettingsPanel() {
|
||||
const [currentTab, setCurrentTab] = useState(ServiceTab.AudioBookShelf);
|
||||
const TabComponent = TAB_COMPONENTS[currentTab];
|
||||
|
||||
return (
|
||||
<div className={classes("vc-plugins-settings", Margins.top16)}>
|
||||
<TabBar
|
||||
type="top"
|
||||
look="brand"
|
||||
selectedItem={currentTab}
|
||||
onItemSelect={setCurrentTab}
|
||||
>
|
||||
{TABS.map(tab => (
|
||||
<TabBar.Item key={tab} id={tab}>
|
||||
{TAB_LABELS[tab]}
|
||||
</TabBar.Item>
|
||||
))}
|
||||
</TabBar>
|
||||
<SwitchSetting key={currentTab} name="Enabled" description={`Enable ${TAB_LABELS[currentTab]} presence.`} settingsKey={ENABLE_KEYS[currentTab]} />
|
||||
<TabComponent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/equicordplugins/richPresence/index.tsx
Normal file
101
src/equicordplugins/richPresence/index.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import definePlugin, { ReporterTestable } from "@utils/types";
|
||||
|
||||
import { migrateOldSettings } from "./migration";
|
||||
import * as abs from "./services/audiobookshelf";
|
||||
import * as gensokyoRadio from "./services/gensokyoRadio";
|
||||
import * as jellyfin from "./services/jellyfin";
|
||||
import * as listenbrainz from "./services/listenbrainz";
|
||||
import * as statsfm from "./services/statsfm";
|
||||
import * as tosu from "./services/tosu";
|
||||
import { setOnServiceChange, settings, SettingsStore } from "./settings";
|
||||
import { ServiceTab } from "./types";
|
||||
|
||||
type SettingsKey = keyof SettingsStore;
|
||||
|
||||
const logger = new Logger("RichPresence");
|
||||
|
||||
const services: Record<string, { start(): void; stop(): void }> = {
|
||||
[ServiceTab.AudioBookShelf]: abs,
|
||||
[ServiceTab.Tosu]: tosu,
|
||||
[ServiceTab.StatsFm]: statsfm,
|
||||
[ServiceTab.Jellyfin]: jellyfin,
|
||||
[ServiceTab.ListenBrainz]: listenbrainz,
|
||||
[ServiceTab.GensokyoRadio]: gensokyoRadio,
|
||||
};
|
||||
|
||||
const enableKeys: Record<string, SettingsKey> = {
|
||||
[ServiceTab.AudioBookShelf]: "abs_enabled",
|
||||
[ServiceTab.Tosu]: "tosu_enabled",
|
||||
[ServiceTab.StatsFm]: "sfm_enabled",
|
||||
[ServiceTab.Jellyfin]: "jf_enabled",
|
||||
[ServiceTab.ListenBrainz]: "lb_enabled",
|
||||
[ServiceTab.GensokyoRadio]: "gr_enabled",
|
||||
};
|
||||
|
||||
const activeServices = new Set<string>();
|
||||
|
||||
function syncServices() {
|
||||
const globalEnabled = settings.store.enabled;
|
||||
|
||||
for (const [id, service] of Object.entries(services)) {
|
||||
const shouldRun =
|
||||
globalEnabled && !!settings.store[enableKeys[id]];
|
||||
const isRunning = activeServices.has(id);
|
||||
|
||||
if (shouldRun && !isRunning) {
|
||||
logger.info(`Starting ${id} service`);
|
||||
service.start();
|
||||
activeServices.add(id);
|
||||
} else if (!shouldRun && isRunning) {
|
||||
logger.info(`Stopping ${id} service`);
|
||||
service.stop();
|
||||
activeServices.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopAllServices() {
|
||||
for (const id of activeServices) {
|
||||
logger.info(`Stopping ${id} service`);
|
||||
services[id].stop();
|
||||
}
|
||||
activeServices.clear();
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "RichPresence",
|
||||
description: "Unified rich presence hub for AudioBookShelf, osu!, stats.fm, Jellyfin, ListenBrainz, and Gensokyo Radio.",
|
||||
authors: [
|
||||
EquicordDevs.vmohammad,
|
||||
Devs.AutumnVN,
|
||||
EquicordDevs.Crxa,
|
||||
Devs.SerStars,
|
||||
EquicordDevs.ZcraftElite,
|
||||
EquicordDevs.qouesm,
|
||||
Devs.RyanCaoDev,
|
||||
EquicordDevs.Prince527,
|
||||
EquicordDevs.creations,
|
||||
],
|
||||
reporterTestable: ReporterTestable.None,
|
||||
|
||||
settings,
|
||||
|
||||
start() {
|
||||
migrateOldSettings();
|
||||
syncServices();
|
||||
setOnServiceChange(syncServices);
|
||||
},
|
||||
|
||||
stop() {
|
||||
stopAllServices();
|
||||
setOnServiceChange(null);
|
||||
},
|
||||
});
|
||||
123
src/equicordplugins/richPresence/migration.ts
Normal file
123
src/equicordplugins/richPresence/migration.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Settings } from "@api/Settings";
|
||||
import { Logger } from "@utils/Logger";
|
||||
|
||||
import { settings, SettingsStore } from "./settings";
|
||||
|
||||
type SettingsKey = keyof SettingsStore;
|
||||
|
||||
const logger = new Logger("RichPresence:Migration");
|
||||
|
||||
interface MigrationMapping {
|
||||
oldPlugin: string;
|
||||
enableKey: SettingsKey;
|
||||
keys: Record<string, SettingsKey>;
|
||||
}
|
||||
|
||||
const migrations: MigrationMapping[] = [
|
||||
{
|
||||
oldPlugin: "AudioBookShelfRichPresence",
|
||||
enableKey: "abs_enabled",
|
||||
keys: {
|
||||
serverUrl: "abs_serverUrl",
|
||||
username: "abs_username",
|
||||
password: "abs_password",
|
||||
},
|
||||
},
|
||||
{
|
||||
oldPlugin: "StatsfmPresence",
|
||||
enableKey: "sfm_enabled",
|
||||
keys: {
|
||||
username: "sfm_username",
|
||||
shareUsername: "sfm_shareUsername",
|
||||
shareSong: "sfm_shareSong",
|
||||
hideWithSpotify: "sfm_hideWithSpotify",
|
||||
hideWithExternalRPC: "sfm_hideWithExternalRPC",
|
||||
statusName: "sfm_statusName",
|
||||
nameFormat: "sfm_nameFormat",
|
||||
useListeningStatus: "sfm_useListeningStatus",
|
||||
missingArt: "sfm_missingArt",
|
||||
showStatsFmLogo: "sfm_showLogo",
|
||||
alwaysHideArt: "sfm_alwaysHideArt",
|
||||
},
|
||||
},
|
||||
{
|
||||
oldPlugin: "JellyfinRichPresence",
|
||||
enableKey: "jf_enabled",
|
||||
keys: {
|
||||
serverUrl: "jf_serverUrl",
|
||||
apiKey: "jf_apiKey",
|
||||
userId: "jf_userId",
|
||||
nameDisplay: "jf_nameDisplay",
|
||||
customName: "jf_customName",
|
||||
coverType: "jf_coverType",
|
||||
episodeFormat: "jf_episodeFormat",
|
||||
showEpisodeName: "jf_showEpisodeName",
|
||||
overrideRichPresenceType: "jf_overrideType",
|
||||
showPausedState: "jf_showPausedState",
|
||||
privacyMode: "jf_privacyMode",
|
||||
},
|
||||
},
|
||||
{
|
||||
oldPlugin: "ListenBrainzRPC",
|
||||
enableKey: "lb_enabled",
|
||||
keys: {
|
||||
username: "lb_username",
|
||||
mbContact: "lb_mbContact",
|
||||
shareUsername: "lb_shareUsername",
|
||||
shareSong: "lb_shareSong",
|
||||
hideWithSpotify: "lb_hideWithSpotify",
|
||||
hideWithActivity: "lb_hideWithActivity",
|
||||
useTimeBar: "lb_useTimeBar",
|
||||
statusName: "lb_statusName",
|
||||
nameFormat: "lb_nameFormat",
|
||||
useListeningStatus: "lb_useListeningStatus",
|
||||
missingArt: "lb_missingArt",
|
||||
useLogo: "lb_useLogo",
|
||||
},
|
||||
},
|
||||
{
|
||||
oldPlugin: "GensokyoRadioRPC",
|
||||
enableKey: "gr_enabled",
|
||||
keys: {
|
||||
refreshInterval: "gr_refreshInterval",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function setStoreValue(key: SettingsKey, value: boolean | string | number) {
|
||||
(settings.store[key] as boolean | string | number) = value;
|
||||
}
|
||||
|
||||
export function migrateOldSettings() {
|
||||
if (Settings.plugins.RichPresence._migrated) return;
|
||||
|
||||
for (const migration of migrations) {
|
||||
const oldSettings = Settings.plugins[migration.oldPlugin];
|
||||
if (!oldSettings) continue;
|
||||
|
||||
if (oldSettings.enabled) {
|
||||
setStoreValue(migration.enableKey, true);
|
||||
}
|
||||
|
||||
for (const [oldKey, newKey] of Object.entries(migration.keys)) {
|
||||
if (oldSettings[oldKey] != null) {
|
||||
setStoreValue(newKey, oldSettings[oldKey]);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Migrated settings from ${migration.oldPlugin}`);
|
||||
}
|
||||
|
||||
const tosuSettings = Settings.plugins.TosuRPC;
|
||||
if (tosuSettings?.enabled) {
|
||||
setStoreValue("tosu_enabled", true);
|
||||
}
|
||||
|
||||
Settings.plugins.RichPresence._migrated = true;
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { TrackData } from "./types";
|
||||
import type { GrTrackData } from "./types/gensokyoRadio";
|
||||
|
||||
export async function fetchTrackData(): Promise<TrackData | null> {
|
||||
export async function fetchTrackData(): Promise<GrTrackData | null> {
|
||||
const song = await (await fetch("https://gensokyoradio.net/api/station/playing/")).json();
|
||||
|
||||
return {
|
||||
|
|
@ -15,6 +15,6 @@ export async function fetchTrackData(): Promise<TrackData | null> {
|
|||
artist: song.SONGINFO.ARTIST,
|
||||
position: song.SONGTIMES.SONGSTART,
|
||||
duration: song.SONGTIMES.SONGEND,
|
||||
artwork: song.MISC.ALBUMART ? `https://gensokyoradio.net/images/albums/500/${song.MISC.ALBUMART}` : "undefined",
|
||||
artwork: song.MISC.ALBUMART ? `https://gensokyoradio.net/images/albums/500/${song.MISC.ALBUMART}` : "",
|
||||
};
|
||||
}
|
||||
148
src/equicordplugins/richPresence/services/audiobookshelf.ts
Normal file
148
src/equicordplugins/richPresence/services/audiobookshelf.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { Activity } from "@vencord/discord-types";
|
||||
import { ApplicationAssetUtils, FluxDispatcher, showToast } from "@webpack/common";
|
||||
|
||||
import { settings } from "../settings";
|
||||
import { AbsMediaData, AbsSession } from "../types/audiobookshelf";
|
||||
|
||||
const APPLICATION_ID = "1381423044907503636";
|
||||
const SOCKET_ID = "RichPresence_ABS";
|
||||
const logger = new Logger("RichPresence:AudioBookShelf");
|
||||
|
||||
let authToken: string | null = null;
|
||||
let updateInterval: NodeJS.Timeout | undefined;
|
||||
|
||||
async function getAsset(key: string): Promise<string> {
|
||||
return (await ApplicationAssetUtils.fetchAssetIds(APPLICATION_ID, [key]))[0];
|
||||
}
|
||||
|
||||
function setActivity(activity: Activity | null) {
|
||||
FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity, socketId: SOCKET_ID });
|
||||
}
|
||||
|
||||
async function authenticate(): Promise<boolean> {
|
||||
const { abs_serverUrl, abs_username, abs_password } = settings.store;
|
||||
if (!abs_serverUrl || !abs_username || !abs_password) {
|
||||
logger.warn("AudioBookShelf server URL, username, or password is not set.");
|
||||
showToast("AudioBookShelf RPC is not configured.", "failure", { duration: 15000 });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = abs_serverUrl.replace(/\/$/, "");
|
||||
const res = await fetch(`${baseUrl}/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: abs_username, password: abs_password }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw `${res.status} ${res.statusText}`;
|
||||
const data = await res.json();
|
||||
authToken = data.user?.token;
|
||||
return !!authToken;
|
||||
} catch (e) {
|
||||
logger.error("Failed to authenticate with AudioBookShelf", e);
|
||||
authToken = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMediaData(): Promise<AbsMediaData | null> {
|
||||
if (!authToken && !(await authenticate())) return null;
|
||||
|
||||
try {
|
||||
const baseUrl = settings.store.abs_serverUrl!.replace(/\/$/, "");
|
||||
const res = await fetch(`${baseUrl}/api/me/listening-sessions`, {
|
||||
headers: { "Authorization": `Bearer ${authToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
authToken = null;
|
||||
if (await authenticate()) return fetchMediaData();
|
||||
}
|
||||
throw `${res.status} ${res.statusText}`;
|
||||
}
|
||||
|
||||
const { sessions }: { sessions: AbsSession[]; } = await res.json();
|
||||
const activeSession = sessions.find(s => s.updatedAt && !s.isFinished);
|
||||
if (!activeSession?.updatedAt || (Date.now() - activeSession.updatedAt) / 1000 > 30) return null;
|
||||
|
||||
const { mediaMetadata: media, mediaType, duration, currentTime, libraryItemId } = activeSession;
|
||||
if (!media) return null;
|
||||
|
||||
return {
|
||||
name: media.title || "Unknown",
|
||||
type: mediaType || "book",
|
||||
author: media.author || media.publisher,
|
||||
series: media.series?.[0]?.name,
|
||||
duration,
|
||||
currentTime,
|
||||
imageUrl: libraryItemId ? `${baseUrl}/api/items/${libraryItemId}/cover` : undefined,
|
||||
isFinished: activeSession.isFinished || false,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.error("Failed to query AudioBookShelf API", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getActivity(): Promise<Activity | null> {
|
||||
const mediaData = await fetchMediaData();
|
||||
if (!mediaData || mediaData.isFinished) return null;
|
||||
|
||||
const largeImage = mediaData.imageUrl;
|
||||
const assets = {
|
||||
large_image: largeImage ? await getAsset(largeImage) : await getAsset("audiobookshelf"),
|
||||
large_text: mediaData.series || mediaData.author || undefined,
|
||||
};
|
||||
|
||||
const details = mediaData.name;
|
||||
const state = mediaData.series && mediaData.author
|
||||
? `${mediaData.series} \u2022 ${mediaData.author}`
|
||||
: mediaData.author || "AudioBook";
|
||||
|
||||
const timestamps = mediaData.currentTime != null && mediaData.duration != null ? {
|
||||
start: Date.now() - (mediaData.currentTime * 1000),
|
||||
end: Date.now() + ((mediaData.duration - mediaData.currentTime) * 1000),
|
||||
} : undefined;
|
||||
|
||||
return {
|
||||
application_id: APPLICATION_ID,
|
||||
name: "AudioBookShelf",
|
||||
details,
|
||||
state,
|
||||
assets,
|
||||
timestamps,
|
||||
type: 2,
|
||||
flags: 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePresence() {
|
||||
try {
|
||||
setActivity(await getActivity());
|
||||
} catch (e) {
|
||||
logger.error("Failed to update presence", e);
|
||||
setActivity(null);
|
||||
}
|
||||
}
|
||||
|
||||
export function start() {
|
||||
authToken = null;
|
||||
updatePresence();
|
||||
updateInterval = setInterval(updatePresence, 10000);
|
||||
}
|
||||
|
||||
export function stop() {
|
||||
clearInterval(updateInterval);
|
||||
updateInterval = undefined;
|
||||
authToken = null;
|
||||
setActivity(null);
|
||||
}
|
||||
73
src/equicordplugins/richPresence/services/gensokyoRadio.ts
Normal file
73
src/equicordplugins/richPresence/services/gensokyoRadio.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { PluginNative } from "@utils/types";
|
||||
import { Activity } from "@vencord/discord-types";
|
||||
import { ActivityFlags, ActivityType } from "@vencord/discord-types/enums";
|
||||
import { ApplicationAssetUtils, FluxDispatcher } from "@webpack/common";
|
||||
|
||||
import { settings } from "../settings";
|
||||
|
||||
const Native = VencordNative.pluginHelpers.RichPresence as PluginNative<typeof import("../native")>;
|
||||
const logger = new Logger("RichPresence:GensokyoRadio");
|
||||
|
||||
const APPLICATION_ID = "1253772057926303804";
|
||||
const SOCKET_ID = "RichPresence_GR";
|
||||
|
||||
let updateInterval: NodeJS.Timeout | undefined;
|
||||
|
||||
function setActivity(activity: Activity | null) {
|
||||
FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity, socketId: SOCKET_ID });
|
||||
}
|
||||
|
||||
async function getAsset(key: string): Promise<string> {
|
||||
return (await ApplicationAssetUtils.fetchAssetIds(APPLICATION_ID, [key]))[0];
|
||||
}
|
||||
|
||||
async function getActivity(): Promise<Activity | null> {
|
||||
const trackData = await Native.fetchTrackData();
|
||||
if (!trackData) return null;
|
||||
|
||||
return {
|
||||
application_id: APPLICATION_ID,
|
||||
name: "Gensokyo Radio",
|
||||
details: trackData.title,
|
||||
state: trackData.artist,
|
||||
timestamps: {
|
||||
start: trackData.position * 1000,
|
||||
end: trackData.duration * 1000,
|
||||
},
|
||||
assets: {
|
||||
large_image: await getAsset(trackData.artwork),
|
||||
large_text: trackData.album,
|
||||
small_image: await getAsset("logo"),
|
||||
small_text: "Gensokyo Radio",
|
||||
},
|
||||
type: ActivityType.LISTENING,
|
||||
flags: ActivityFlags.INSTANCE,
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePresence() {
|
||||
try {
|
||||
setActivity(await getActivity());
|
||||
} catch (e) {
|
||||
logger.error("Failed to update presence", e);
|
||||
setActivity(null);
|
||||
}
|
||||
}
|
||||
|
||||
export function start() {
|
||||
updatePresence();
|
||||
updateInterval = setInterval(updatePresence, (settings.store.gr_refreshInterval ?? 15) * 1000);
|
||||
}
|
||||
|
||||
export function stop() {
|
||||
clearInterval(updateInterval);
|
||||
updateInterval = undefined;
|
||||
setActivity(null);
|
||||
}
|
||||
231
src/equicordplugins/richPresence/services/jellyfin.ts
Normal file
231
src/equicordplugins/richPresence/services/jellyfin.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { Activity } from "@vencord/discord-types";
|
||||
import { ApplicationAssetUtils, FluxDispatcher, showToast } from "@webpack/common";
|
||||
|
||||
import { settings } from "../settings";
|
||||
import { JfMediaData, JfSession } from "../types/jellyfin";
|
||||
|
||||
const APPLICATION_ID = "1381368130164625469";
|
||||
const SOCKET_ID = "RichPresence_JF";
|
||||
const logger = new Logger("RichPresence:Jellyfin");
|
||||
|
||||
let updateInterval: NodeJS.Timeout | undefined;
|
||||
let hasShownError = false;
|
||||
|
||||
async function getAsset(key: string): Promise<string> {
|
||||
return (await ApplicationAssetUtils.fetchAssetIds(APPLICATION_ID, [key]))[0];
|
||||
}
|
||||
|
||||
function setActivity(activity: Activity | null) {
|
||||
FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity, socketId: SOCKET_ID });
|
||||
}
|
||||
|
||||
async function fetchMediaData(): Promise<JfMediaData | null> {
|
||||
const { jf_serverUrl, jf_apiKey, jf_userId } = settings.store;
|
||||
if (!jf_serverUrl || !jf_apiKey || !jf_userId) {
|
||||
if (!hasShownError) {
|
||||
logger.warn("Jellyfin server URL, API key, or user ID is not set.");
|
||||
showToast("Jellyfin RPC is not configured.", "failure", { duration: 15000 });
|
||||
hasShownError = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = (jf_serverUrl.startsWith("http") ? jf_serverUrl : `https://${jf_serverUrl}`).replace(/\/$/, "");
|
||||
const res = await fetch(`${baseUrl}/Sessions?api_key=${jf_apiKey}`);
|
||||
if (!res.ok) throw `${res.status} ${res.statusText}`;
|
||||
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
if (!hasShownError) {
|
||||
logger.error("Jellyfin returned non-JSON response. Check your server URL and API key.");
|
||||
showToast("Jellyfin returned an invalid response. Your API key may be wrong.", "failure", { duration: 15000 });
|
||||
hasShownError = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessions: JfSession[] = await res.json();
|
||||
const userSession = sessions.find(s => s.UserId === jf_userId && s.NowPlayingItem);
|
||||
if (!userSession?.NowPlayingItem) return null;
|
||||
|
||||
const item = userSession.NowPlayingItem;
|
||||
const playState = userSession.PlayState;
|
||||
|
||||
if (playState?.IsPaused && !settings.store.jf_showPausedState) return null;
|
||||
|
||||
const imageUrl = item.ImageTags?.Primary
|
||||
? `${baseUrl}/Items/${item.Type === "Episode" && item.SeriesId && settings.store.jf_coverType === "series"
|
||||
? item.SeriesId : item.Id}/Images/Primary`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
name: item.Name || "Unknown",
|
||||
type: item.Type || "Unknown",
|
||||
artist: item.Artists?.[0] || item.AlbumArtist,
|
||||
album: item.Album,
|
||||
seriesName: item.SeriesName,
|
||||
seasonNumber: item.ParentIndexNumber,
|
||||
episodeNumber: item.IndexNumber,
|
||||
year: item.ProductionYear,
|
||||
url: `${baseUrl}/web/#!/details?id=${item.Id}`,
|
||||
imageUrl,
|
||||
duration: item.RunTimeTicks ? Math.floor(item.RunTimeTicks / 10000000) : undefined,
|
||||
position: playState?.PositionTicks ? Math.floor(playState.PositionTicks / 10000000) : undefined,
|
||||
isPaused: !!playState?.IsPaused,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.error("Failed to query Jellyfin API", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getActivity(): Promise<Activity | null> {
|
||||
const { store } = settings;
|
||||
const mediaData = await fetchMediaData();
|
||||
if (!mediaData) return null;
|
||||
|
||||
let richPresenceType: number;
|
||||
if (store.jf_overrideType !== "off") {
|
||||
richPresenceType = parseInt(store.jf_overrideType as string, 10);
|
||||
} else {
|
||||
richPresenceType = mediaData.type === "Audio" ? 2 : 3;
|
||||
}
|
||||
|
||||
const templateReplace = (template: string) =>
|
||||
template
|
||||
.replace(/\{name\}/g, mediaData.name || "")
|
||||
.replace(/\{series\}/g, mediaData.seriesName || "")
|
||||
.replace(/\{season\}/g, mediaData.seasonNumber?.toString() || "")
|
||||
.replace(/\{episode\}/g, mediaData.episodeNumber?.toString() || "")
|
||||
.replace(/\{artist\}/g, mediaData.artist || "")
|
||||
.replace(/\{album\}/g, mediaData.album || "")
|
||||
.replace(/\{year\}/g, mediaData.year?.toString() || "");
|
||||
|
||||
let appName: string;
|
||||
const nameSetting = store.jf_nameDisplay || "default";
|
||||
|
||||
switch (nameSetting) {
|
||||
case "full":
|
||||
if (mediaData.type === "Episode" && mediaData.seriesName) {
|
||||
appName = store.jf_privacyMode
|
||||
? `${mediaData.seriesName} - [Episode Hidden]`
|
||||
: `${mediaData.seriesName} - ${mediaData.name}`;
|
||||
} else if (mediaData.type === "Audio") {
|
||||
appName = store.jf_privacyMode
|
||||
? "[Track Hidden]"
|
||||
: `${mediaData.artist || "Unknown Artist"} - ${mediaData.name}`;
|
||||
} else {
|
||||
appName = store.jf_privacyMode ? "[Movie Hidden]" : mediaData.name || "Jellyfin";
|
||||
}
|
||||
break;
|
||||
case "custom":
|
||||
appName = templateReplace(store.jf_customName || "{name} on Jellyfin");
|
||||
if (store.jf_privacyMode) {
|
||||
appName = appName
|
||||
.replace(mediaData.name || "", "[Title Hidden]")
|
||||
.replace(mediaData.seriesName || "", "[Series Hidden]")
|
||||
.replace(mediaData.artist || "", "[Artist Hidden]")
|
||||
.replace(mediaData.album || "", "[Album Hidden]");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (mediaData.type === "Episode" && mediaData.seriesName) {
|
||||
appName = mediaData.seriesName;
|
||||
} else {
|
||||
appName = store.jf_privacyMode ? "[Media Hidden]" : mediaData.name || "Jellyfin";
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const assets = {
|
||||
large_image: !store.jf_privacyMode && mediaData.imageUrl
|
||||
? await getAsset(mediaData.imageUrl) : undefined,
|
||||
large_text: mediaData.seriesName || mediaData.album || undefined,
|
||||
};
|
||||
|
||||
const getDetails = () => {
|
||||
if (mediaData.type === "Episode" && mediaData.seriesName)
|
||||
return store.jf_privacyMode ? "Watching a TV Show" : mediaData.seriesName;
|
||||
return store.jf_privacyMode ? "Watching Something" : mediaData.name;
|
||||
};
|
||||
|
||||
const getState = () => {
|
||||
if (mediaData.isPaused) return "Paused";
|
||||
if (mediaData.type === "Episode" && mediaData.seriesName) {
|
||||
let episodeFormat = "";
|
||||
const season = mediaData.seasonNumber;
|
||||
const episode = mediaData.episodeNumber;
|
||||
const format = store.jf_episodeFormat || "long";
|
||||
|
||||
if (season != null && episode != null) {
|
||||
switch (format) {
|
||||
case "long":
|
||||
episodeFormat = `S${season.toString().padStart(2, "0")}E${episode.toString().padStart(2, "0")}`;
|
||||
break;
|
||||
case "short":
|
||||
episodeFormat = `${season}x${episode.toString().padStart(2, "0")}`;
|
||||
break;
|
||||
case "fulltext":
|
||||
episodeFormat = `Season ${season} Episode ${episode}`;
|
||||
break;
|
||||
}
|
||||
} else if (season != null) {
|
||||
episodeFormat = format === "fulltext" ? `Season ${season}` : `S${season.toString().padStart(2, "0")}`;
|
||||
} else if (episode != null) {
|
||||
episodeFormat = format === "fulltext" ? `Episode ${episode}` : `E${episode.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
if (store.jf_showEpisodeName && mediaData.name && !store.jf_privacyMode)
|
||||
return `${episodeFormat} - ${mediaData.name}`;
|
||||
return episodeFormat;
|
||||
}
|
||||
if (store.jf_privacyMode)
|
||||
return mediaData.type === "Audio" ? "Listening to music" : (mediaData.year ? "(????)" : undefined);
|
||||
return mediaData.artist || (mediaData.year ? `(${mediaData.year})` : undefined);
|
||||
};
|
||||
|
||||
const timestamps = (!mediaData.isPaused && mediaData.position != null && mediaData.duration != null) ? {
|
||||
start: Date.now() - (mediaData.position * 1000),
|
||||
end: Date.now() + ((mediaData.duration - mediaData.position) * 1000),
|
||||
} : undefined;
|
||||
|
||||
return {
|
||||
application_id: APPLICATION_ID,
|
||||
name: appName,
|
||||
details: getDetails(),
|
||||
state: getState() || "something",
|
||||
assets,
|
||||
timestamps,
|
||||
type: richPresenceType,
|
||||
flags: 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePresence() {
|
||||
try {
|
||||
setActivity(await getActivity());
|
||||
} catch (e) {
|
||||
logger.error("Failed to update presence", e);
|
||||
setActivity(null);
|
||||
}
|
||||
}
|
||||
|
||||
export function start() {
|
||||
hasShownError = false;
|
||||
updatePresence();
|
||||
updateInterval = setInterval(updatePresence, 10000);
|
||||
}
|
||||
|
||||
export function stop() {
|
||||
clearInterval(updateInterval);
|
||||
updateInterval = undefined;
|
||||
setActivity(null);
|
||||
}
|
||||
186
src/equicordplugins/richPresence/services/listenbrainz.ts
Normal file
186
src/equicordplugins/richPresence/services/listenbrainz.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { Activity, ActivityButton } from "@vencord/discord-types";
|
||||
import { ActivityFlags, ActivityType } from "@vencord/discord-types/enums";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { ApplicationAssetUtils, FluxDispatcher } from "@webpack/common";
|
||||
|
||||
import { settings } from "../settings";
|
||||
import { NameFormat } from "../types";
|
||||
import { LbTrackData } from "../types/listenbrainz";
|
||||
|
||||
const APPLICATION_ID = "1090155131007406132";
|
||||
const PLACEHOLDER_ID = "2a96cbd8b46e442fc41c2b86b821562f";
|
||||
const SOCKET_ID = "RichPresence_LB";
|
||||
const logger = new Logger("RichPresence:ListenBrainz");
|
||||
const PresenceStore = findByPropsLazy("getLocalPresence");
|
||||
|
||||
let updateInterval: NodeJS.Timeout | undefined;
|
||||
let currentRecordingMBID = "";
|
||||
let currentStart = 0;
|
||||
|
||||
async function getAsset(key: string): Promise<string> {
|
||||
return (await ApplicationAssetUtils.fetchAssetIds(APPLICATION_ID, [key]))[0];
|
||||
}
|
||||
|
||||
function setActivity(activity: Activity | null) {
|
||||
FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity, socketId: SOCKET_ID });
|
||||
}
|
||||
|
||||
async function fetchTrackData(): Promise<LbTrackData | null> {
|
||||
if (!settings.store.lb_username) {
|
||||
logger.warn("ListenBrainz username is not set.");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const lbRes = await fetch(`https://api.listenbrainz.org/1/user/${settings.store.lb_username}/playing-now`);
|
||||
if (!lbRes.ok) throw `${lbRes.status} ${lbRes.statusText}`;
|
||||
|
||||
const lbJson = await lbRes.json();
|
||||
if (lbJson.error) {
|
||||
logger.error("Error from ListenBrainz API", `${lbJson.error}: ${lbJson.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const listen = lbJson.payload?.listens?.[0];
|
||||
if (!listen?.playing_now) return null;
|
||||
|
||||
const trackMetadata = listen.track_metadata;
|
||||
const albumName = trackMetadata.release_name || "Unknown";
|
||||
const artistName = trackMetadata.artist_name || "Unknown";
|
||||
|
||||
const mbRes = await fetch(
|
||||
`https://musicbrainz.org/ws/2/release/?query=release:${encodeURIComponent(albumName)}%20AND%20artist:${encodeURIComponent(artistName)}&fmt=json`
|
||||
);
|
||||
if (!mbRes.ok) throw `${mbRes.status} ${mbRes.statusText}`;
|
||||
|
||||
const mbJson = await mbRes.json();
|
||||
const releases = mbJson.releases || [];
|
||||
const releaseGroup = releases[0]?.["release-group"]?.id;
|
||||
|
||||
let url = "";
|
||||
let imageUrl = "";
|
||||
|
||||
if (releaseGroup) {
|
||||
const caaRes = await fetch(`https://coverartarchive.org/release-group/${releaseGroup}`);
|
||||
if (caaRes.ok) {
|
||||
const caaJson = await caaRes.json();
|
||||
url = caaJson.release ?? "";
|
||||
for (const image of caaJson.images) {
|
||||
imageUrl = image.thumbnails.large || "";
|
||||
if (imageUrl) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: trackMetadata.track_name || "Unknown",
|
||||
album: albumName,
|
||||
artist: artistName,
|
||||
durationMs: trackMetadata.additional_info?.duration_ms,
|
||||
recordingMBID: trackMetadata.additional_info?.recording_mbid,
|
||||
url,
|
||||
imageUrl,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.error("Failed to query ListenBrainz API", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLargeImage(track: LbTrackData): string | undefined {
|
||||
if (track.imageUrl && !track.imageUrl.includes(PLACEHOLDER_ID)) return track.imageUrl;
|
||||
if (settings.store.lb_missingArt === "placeholder") return "placeholder";
|
||||
}
|
||||
|
||||
async function getActivity(): Promise<Activity | null> {
|
||||
if (settings.store.lb_hideWithActivity) {
|
||||
if (PresenceStore.getActivities().some(a => a.application_id !== APPLICATION_ID)) return null;
|
||||
}
|
||||
|
||||
if (settings.store.lb_hideWithSpotify) {
|
||||
if (PresenceStore.getActivities().some(a => a.type === ActivityType.LISTENING && a.application_id !== APPLICATION_ID))
|
||||
return null;
|
||||
}
|
||||
|
||||
const trackData = await fetchTrackData();
|
||||
if (!trackData) return null;
|
||||
|
||||
const largeImage = getLargeImage(trackData);
|
||||
const assets = largeImage
|
||||
? {
|
||||
large_image: await getAsset(largeImage),
|
||||
large_text: trackData.album || undefined,
|
||||
small_image: settings.store.lb_useLogo ? await getAsset("listenbrainz") : undefined,
|
||||
small_text: "ListenBrainz",
|
||||
}
|
||||
: {
|
||||
large_image: await getAsset("listenbrainz"),
|
||||
large_text: trackData.album || undefined,
|
||||
};
|
||||
|
||||
const buttons: ActivityButton[] = [];
|
||||
if (settings.store.lb_shareUsername)
|
||||
buttons.push({ label: "ListenBrainz Profile", url: `https://www.listenbrainz.org/user/${settings.store.lb_username}` });
|
||||
if (settings.store.lb_shareSong && trackData.url)
|
||||
buttons.push({ label: "View Song", url: trackData.url });
|
||||
|
||||
const statusName = (() => {
|
||||
switch (settings.store.lb_nameFormat) {
|
||||
case NameFormat.ArtistFirst: return trackData.artist + " - " + trackData.name;
|
||||
case NameFormat.SongFirst: return trackData.name + " - " + trackData.artist;
|
||||
case NameFormat.ArtistOnly: return trackData.artist;
|
||||
case NameFormat.SongOnly: return trackData.name;
|
||||
case NameFormat.AlbumName: return trackData.album || settings.store.lb_statusName;
|
||||
default: return settings.store.lb_statusName;
|
||||
}
|
||||
})();
|
||||
|
||||
if (trackData.recordingMBID && trackData.recordingMBID !== currentRecordingMBID) {
|
||||
currentRecordingMBID = trackData.recordingMBID;
|
||||
currentStart = Date.now();
|
||||
}
|
||||
|
||||
return {
|
||||
application_id: APPLICATION_ID,
|
||||
name: statusName,
|
||||
details: trackData.name,
|
||||
state: trackData.artist,
|
||||
assets,
|
||||
timestamps: settings.store.lb_useTimeBar && currentStart
|
||||
? { start: currentStart, end: trackData.durationMs ? currentStart + trackData.durationMs : undefined }
|
||||
: undefined,
|
||||
buttons: buttons.length ? buttons.map(v => v.label) : undefined,
|
||||
metadata: buttons.length ? { button_urls: buttons.map(v => v.url) } : undefined,
|
||||
type: settings.store.lb_useListeningStatus ? ActivityType.LISTENING : ActivityType.PLAYING,
|
||||
flags: ActivityFlags.INSTANCE,
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePresence() {
|
||||
try {
|
||||
setActivity(await getActivity());
|
||||
} catch (e) {
|
||||
logger.error("Failed to update presence", e);
|
||||
setActivity(null);
|
||||
}
|
||||
}
|
||||
|
||||
export function start() {
|
||||
currentRecordingMBID = "";
|
||||
currentStart = 0;
|
||||
updatePresence();
|
||||
updateInterval = setInterval(updatePresence, 16000);
|
||||
}
|
||||
|
||||
export function stop() {
|
||||
clearInterval(updateInterval);
|
||||
updateInterval = undefined;
|
||||
setActivity(null);
|
||||
}
|
||||
143
src/equicordplugins/richPresence/services/statsfm.ts
Normal file
143
src/equicordplugins/richPresence/services/statsfm.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { Activity, ActivityButton } from "@vencord/discord-types";
|
||||
import { ActivityFlags, ActivityType } from "@vencord/discord-types/enums";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { ApplicationAssetUtils, FluxDispatcher } from "@webpack/common";
|
||||
|
||||
import { settings } from "../settings";
|
||||
import { NameFormat } from "../types";
|
||||
import { SfmResponse, SfmTrackData } from "../types/statsfm";
|
||||
|
||||
const APPLICATION_ID = "1325126169179197500";
|
||||
const PLACEHOLDER_ID = "2a96cbd8b46e442fc41c2b86b821562f";
|
||||
const SOCKET_ID = "RichPresence_SFM";
|
||||
const logger = new Logger("RichPresence:StatsFm");
|
||||
const PresenceStore = findByPropsLazy("getLocalPresence");
|
||||
|
||||
let updateInterval: NodeJS.Timeout | undefined;
|
||||
|
||||
async function getAsset(key: string): Promise<string> {
|
||||
return (await ApplicationAssetUtils.fetchAssetIds(APPLICATION_ID, [key]))[0];
|
||||
}
|
||||
|
||||
function setActivity(activity: Activity | null) {
|
||||
FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity, socketId: SOCKET_ID });
|
||||
}
|
||||
|
||||
async function fetchTrackData(): Promise<SfmTrackData | null> {
|
||||
if (!settings.store.sfm_username) return null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://api.stats.fm/api/v1/users/${settings.store.sfm_username}/streams/current`);
|
||||
if (!res.ok) throw `${res.status} ${res.statusText}`;
|
||||
|
||||
const json = await res.json() as SfmResponse;
|
||||
if (!json.item) {
|
||||
logger.error("Error from Stats.fm API", json);
|
||||
return null;
|
||||
}
|
||||
|
||||
const trackData = json.item.track;
|
||||
if (!trackData) return null;
|
||||
|
||||
return {
|
||||
name: trackData.name || "Unknown",
|
||||
albums: trackData.albums.map(a => a.name).join(", ") || "Unknown",
|
||||
artists: trackData.artists[0]?.name ?? "Unknown",
|
||||
url: `https://stats.fm/track/${trackData.id}`,
|
||||
imageUrl: trackData.albums[0]?.image,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.error("Failed to query Stats.fm API", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLargeImage(track: SfmTrackData): string | undefined {
|
||||
if (!settings.store.sfm_alwaysHideArt && track.imageUrl && !track.imageUrl.includes(PLACEHOLDER_ID))
|
||||
return track.imageUrl;
|
||||
if (settings.store.sfm_missingArt === "placeholder") return "placeholder";
|
||||
}
|
||||
|
||||
async function getActivity(): Promise<Activity | null> {
|
||||
if (settings.store.sfm_hideWithExternalRPC) {
|
||||
if (PresenceStore.getActivities().some(a => a.application_id !== APPLICATION_ID)) return null;
|
||||
}
|
||||
|
||||
if (settings.store.sfm_hideWithSpotify) {
|
||||
if (PresenceStore.getActivities().some(a => a.type === ActivityType.LISTENING && a.application_id !== APPLICATION_ID))
|
||||
return null;
|
||||
}
|
||||
|
||||
const trackData = await fetchTrackData();
|
||||
if (!trackData) return null;
|
||||
|
||||
const largeImage = getLargeImage(trackData);
|
||||
const assets = largeImage
|
||||
? {
|
||||
large_image: await getAsset(largeImage),
|
||||
large_text: trackData.albums || undefined,
|
||||
...(settings.store.sfm_showLogo && {
|
||||
small_image: await getAsset("statsfm-large"),
|
||||
small_text: "Stats.fm",
|
||||
}),
|
||||
} : {
|
||||
large_image: await getAsset("statsfm-large"),
|
||||
large_text: trackData.albums || undefined,
|
||||
};
|
||||
|
||||
const buttons: ActivityButton[] = [];
|
||||
if (settings.store.sfm_shareUsername)
|
||||
buttons.push({ label: "Stats.fm Profile", url: `https://stats.fm/${settings.store.sfm_username}` });
|
||||
if (settings.store.sfm_shareSong)
|
||||
buttons.push({ label: "View Song", url: trackData.url });
|
||||
|
||||
const statusName = (() => {
|
||||
switch (settings.store.sfm_nameFormat) {
|
||||
case NameFormat.ArtistFirst: return trackData.artists + " - " + trackData.name;
|
||||
case NameFormat.SongFirst: return trackData.name + " - " + trackData.artists;
|
||||
case NameFormat.ArtistOnly: return trackData.artists;
|
||||
case NameFormat.SongOnly: return trackData.name;
|
||||
case NameFormat.AlbumName: return trackData.albums || settings.store.sfm_statusName;
|
||||
default: return settings.store.sfm_statusName;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
application_id: APPLICATION_ID,
|
||||
name: statusName,
|
||||
details: trackData.name,
|
||||
state: trackData.artists,
|
||||
assets,
|
||||
buttons: buttons.length ? buttons.map(v => v.label) : undefined,
|
||||
metadata: buttons.length ? { button_urls: buttons.map(v => v.url) } : undefined,
|
||||
type: settings.store.sfm_useListeningStatus ? ActivityType.LISTENING : ActivityType.PLAYING,
|
||||
flags: ActivityFlags.INSTANCE,
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePresence() {
|
||||
try {
|
||||
setActivity(await getActivity());
|
||||
} catch (e) {
|
||||
logger.error("Failed to update presence", e);
|
||||
setActivity(null);
|
||||
}
|
||||
}
|
||||
|
||||
export function start() {
|
||||
updatePresence();
|
||||
updateInterval = setInterval(updatePresence, 16000);
|
||||
}
|
||||
|
||||
export function stop() {
|
||||
clearInterval(updateInterval);
|
||||
updateInterval = undefined;
|
||||
setActivity(null);
|
||||
}
|
||||
|
|
@ -1,98 +1,90 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { Activity } from "@vencord/discord-types";
|
||||
import { ActivityType } from "@vencord/discord-types/enums";
|
||||
import { ApplicationAssetUtils, FluxDispatcher } from "@webpack/common";
|
||||
|
||||
import { BanchoStatusEnum, GameState, Modes, TosuApi, UserLoginStatus } from "./type";
|
||||
import { BanchoStatusEnum, GameState, Modes, TosuApi } from "../types/tosu";
|
||||
|
||||
const socketId = "tosu";
|
||||
const OSU_APP_ID = "367827983903490050";
|
||||
const OSU_LARGE_IMAGE = "373344233077211136";
|
||||
const OSU_STARDARD_SMALL_IMAGE = "373370493127884800";
|
||||
const OSU_MANIA_SMALL_IMAGE = "373370588703621136";
|
||||
const OSU_TAIKO_SMALL_IMAGE = "373370519891738624";
|
||||
const OSU_CATCH_SMALL_IMAGE = "373370543161999361";
|
||||
|
||||
const throttledOnMessage = throttle(onMessage, 3000, () => FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null, socketId: "tosu" }));
|
||||
const SOCKET_ID = "RichPresence_Tosu";
|
||||
|
||||
let ws: WebSocket;
|
||||
let wsReconnect: NodeJS.Timeout;
|
||||
let shouldReconnect = false;
|
||||
|
||||
export default definePlugin({
|
||||
name: "TosuRPC",
|
||||
description: "osu! RPC with data from tosu",
|
||||
authors: [Devs.AutumnVN],
|
||||
start() {
|
||||
shouldReconnect = true;
|
||||
(function connect() {
|
||||
ws = new WebSocket("ws://127.0.0.1:24050/websocket/v2");
|
||||
ws.addEventListener("error", () => ws.close());
|
||||
ws.addEventListener("close", () => {
|
||||
if (!shouldReconnect) return;
|
||||
wsReconnect = setTimeout(connect, 5000);
|
||||
});
|
||||
ws.addEventListener("message", ({ data }) => throttledOnMessage(data));
|
||||
})();
|
||||
},
|
||||
stop() {
|
||||
shouldReconnect = false;
|
||||
if (wsReconnect) clearTimeout(wsReconnect);
|
||||
if (ws) ws.close();
|
||||
FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null, socketId: "tosu" });
|
||||
}
|
||||
});
|
||||
function throttle<T extends (...args: never[]) => void>(func: T, limit: number, timedOutCallback?: () => void): T {
|
||||
let inThrottle: boolean;
|
||||
let callbackTimeout: NodeJS.Timeout;
|
||||
return function (...args: Parameters<T>) {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
if (timedOutCallback) {
|
||||
clearTimeout(callbackTimeout);
|
||||
callbackTimeout = setTimeout(timedOutCallback, limit * 2);
|
||||
}
|
||||
}
|
||||
} as T;
|
||||
}
|
||||
|
||||
async function getAsset(key: string): Promise<string> {
|
||||
if (/https?:\/\/(cdn|media)\.discordapp\.(com|net)\/attachments\//.test(key))
|
||||
return "mp:" + key.replace(/https?:\/\/(cdn|media)\.discordapp\.(com|net)\//, "");
|
||||
return (await ApplicationAssetUtils.fetchAssetIds(OSU_APP_ID, [key]))[0];
|
||||
}
|
||||
|
||||
const throttledOnMessage = throttle(onMessage, 3000, () =>
|
||||
FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null, socketId: SOCKET_ID })
|
||||
);
|
||||
|
||||
async function onMessage(data: string) {
|
||||
const json: TosuApi = JSON.parse(data);
|
||||
// @ts-ignore
|
||||
if (json.error) return FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null, socketId: "tosu" });
|
||||
if (json.error) return FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null, socketId: SOCKET_ID });
|
||||
|
||||
const { state, session, profile, beatmap, play, resultsScreen } = json;
|
||||
|
||||
const assets: NonNullable<Activity["assets"]> = {};
|
||||
|
||||
switch (profile.mode.number) {
|
||||
case Modes.Osu:
|
||||
assets.small_image = OSU_STARDARD_SMALL_IMAGE;
|
||||
assets.small_text = "osu!";
|
||||
break;
|
||||
case Modes.Mania:
|
||||
assets.small_image = OSU_MANIA_SMALL_IMAGE;
|
||||
assets.small_text = "osu!mania";
|
||||
break;
|
||||
case Modes.Taiko:
|
||||
assets.small_image = OSU_TAIKO_SMALL_IMAGE;
|
||||
assets.small_text = "osu!taiko";
|
||||
break;
|
||||
case Modes.Fruits:
|
||||
assets.small_image = OSU_CATCH_SMALL_IMAGE;
|
||||
assets.small_text = "osu!catch";
|
||||
break;
|
||||
}
|
||||
|
||||
const activity: Activity = {
|
||||
application_id: OSU_APP_ID,
|
||||
name: "osu!",
|
||||
type: ActivityType.PLAYING,
|
||||
assets: {
|
||||
large_image: OSU_LARGE_IMAGE,
|
||||
large_text: profile.userStatus.number === UserLoginStatus.Connected ? `${profile.name} | #${profile.globalRank} | ${Math.round(profile.pp)}pp` : undefined,
|
||||
},
|
||||
timestamps: {
|
||||
start: Date.now() - session.playTime
|
||||
},
|
||||
assets,
|
||||
timestamps: { start: Date.now() - session.playTime },
|
||||
flags: 1 << 0,
|
||||
};
|
||||
|
||||
activity.assets = {};
|
||||
|
||||
switch (profile.mode.number) {
|
||||
case Modes.Osu:
|
||||
activity.assets.small_image = OSU_STARDARD_SMALL_IMAGE;
|
||||
activity.assets.small_text = "osu!";
|
||||
break;
|
||||
case Modes.Mania:
|
||||
activity.assets.small_image = OSU_MANIA_SMALL_IMAGE;
|
||||
activity.assets.small_text = "osu!mania";
|
||||
break;
|
||||
case Modes.Taiko:
|
||||
activity.assets.small_image = OSU_TAIKO_SMALL_IMAGE;
|
||||
activity.assets.small_text = "osu!taiko";
|
||||
break;
|
||||
case Modes.Fruits:
|
||||
activity.assets.small_image = OSU_CATCH_SMALL_IMAGE;
|
||||
activity.assets.small_text = "osu!catch";
|
||||
break;
|
||||
}
|
||||
|
||||
let player = "";
|
||||
let mods = "";
|
||||
let fc = "";
|
||||
let combo = "";
|
||||
|
|
@ -101,11 +93,13 @@ async function onMessage(data: string) {
|
|||
let h0 = "";
|
||||
let sb = "";
|
||||
let pp = "";
|
||||
switch (state.number) {
|
||||
case GameState.Play:
|
||||
activity.type = profile.banchoStatus.number === BanchoStatusEnum.Playing ? ActivityType.PLAYING : ActivityType.WATCHING;
|
||||
|
||||
player = profile.banchoStatus.number === BanchoStatusEnum.Playing ? "" : `${play.playerName} | `;
|
||||
switch (state.number) {
|
||||
case GameState.Play: {
|
||||
activity.type = profile.banchoStatus.number === BanchoStatusEnum.Playing
|
||||
? ActivityType.PLAYING : ActivityType.WATCHING;
|
||||
|
||||
const player = profile.banchoStatus.number === BanchoStatusEnum.Playing ? "" : `${play.playerName} | `;
|
||||
mods = play.mods.name ? `+${play.mods.name} ` : "";
|
||||
activity.name = `${player}${beatmap.artist} - ${beatmap.title} [${beatmap.version}] ${mods}(${beatmap.mapper}, ${beatmap.stats.stars.total.toFixed(2)}*)`;
|
||||
|
||||
|
|
@ -124,10 +118,11 @@ async function onMessage(data: string) {
|
|||
activity.state = [h100, h50, h0, sb].filter(Boolean).join(" | ");
|
||||
|
||||
const playRank = await getAsset(`https://raw.githubusercontent.com/AutumnVN/gosu-rich-presence/main/grade/${play.rank.current.toLowerCase().replace("x", "ss")}.png`);
|
||||
activity.assets.small_image = playRank;
|
||||
activity.assets.small_text = undefined;
|
||||
assets.small_image = playRank;
|
||||
assets.small_text = undefined;
|
||||
break;
|
||||
case GameState.ResultScreen:
|
||||
}
|
||||
case GameState.ResultScreen: {
|
||||
activity.type = ActivityType.WATCHING;
|
||||
|
||||
mods = resultsScreen.mods.name ? `+${resultsScreen.mods.name} ` : "";
|
||||
|
|
@ -147,10 +142,11 @@ async function onMessage(data: string) {
|
|||
activity.state = [h100, h50, h0].filter(Boolean).join(" | ");
|
||||
|
||||
const resultRank = await getAsset(`https://raw.githubusercontent.com/AutumnVN/gosu-rich-presence/main/grade/${resultsScreen.rank.toLowerCase().replace("x", "ss")}.png`);
|
||||
activity.assets.small_image = resultRank;
|
||||
activity.assets.small_text = undefined;
|
||||
assets.small_image = resultRank;
|
||||
assets.small_text = undefined;
|
||||
break;
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
activity.type = ActivityType.LISTENING;
|
||||
mods = play.mods.name ? `+${play.mods.name} ` : "";
|
||||
activity.name = `${beatmap.artist} - ${beatmap.title} [${beatmap.version}] ${mods}(${beatmap.mapper}, ${beatmap.stats.stars.total.toFixed(2)}*)`;
|
||||
|
|
@ -193,36 +189,35 @@ async function onMessage(data: string) {
|
|||
case BanchoStatusEnum.Multiplaying: activity.state = "Multiplaying"; break;
|
||||
case BanchoStatusEnum.OsuDirect: activity.state = "osu!direct"; break;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (beatmap.set > 0) {
|
||||
const mapBg = await getAsset(`https://assets.ppy.sh/beatmaps/${beatmap.set}/covers/list@2x.jpg`);
|
||||
const res = await fetch(mapBg.replace(/^mp:/, "https://media.discordapp.net/"), { method: "HEAD" });
|
||||
if (res.ok) activity.assets.large_image = mapBg;
|
||||
if (res.ok) assets.large_image = mapBg;
|
||||
}
|
||||
|
||||
FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity, socketId: "tosu" });
|
||||
FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity, socketId: SOCKET_ID });
|
||||
}
|
||||
|
||||
function throttle<T extends Function>(func: T, limit: number, timedOutCallback?: () => void): T {
|
||||
let inThrottle: boolean;
|
||||
let callbackTimeout: NodeJS.Timeout;
|
||||
return function (this: any, ...args: any[]) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
if (timedOutCallback) {
|
||||
clearTimeout(callbackTimeout);
|
||||
callbackTimeout = setTimeout(timedOutCallback, limit * 2);
|
||||
}
|
||||
}
|
||||
} as any;
|
||||
export function start() {
|
||||
shouldReconnect = true;
|
||||
(function connect() {
|
||||
ws = new WebSocket("ws://127.0.0.1:24050/websocket/v2");
|
||||
ws.addEventListener("error", () => ws.close());
|
||||
ws.addEventListener("close", () => {
|
||||
if (!shouldReconnect) return;
|
||||
wsReconnect = setTimeout(connect, 5000);
|
||||
});
|
||||
ws.addEventListener("message", ({ data }) => throttledOnMessage(data));
|
||||
})();
|
||||
}
|
||||
|
||||
async function getAsset(key: string): Promise<string> {
|
||||
if (/https?:\/\/(cdn|media)\.discordapp\.(com|net)\/attachments\//.test(key)) return "mp:" + key.replace(/https?:\/\/(cdn|media)\.discordapp\.(com|net)\//, "");
|
||||
return (await ApplicationAssetUtils.fetchAssetIds(OSU_APP_ID, [key]))[0];
|
||||
export function stop() {
|
||||
shouldReconnect = false;
|
||||
if (wsReconnect) clearTimeout(wsReconnect);
|
||||
if (ws) ws.close();
|
||||
FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null, socketId: SOCKET_ID });
|
||||
}
|
||||
351
src/equicordplugins/richPresence/settings.ts
Normal file
351
src/equicordplugins/richPresence/settings.ts
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { OptionType } from "@utils/types";
|
||||
|
||||
import { SettingsPanel } from "./SettingsPanel";
|
||||
import { NameFormat } from "./types";
|
||||
|
||||
export let onServiceChange: (() => void) | null = null;
|
||||
export function setOnServiceChange(fn: (() => void) | null) { onServiceChange = fn; }
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
enabled: {
|
||||
description: "Enable rich presence services.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
restartNeeded: false,
|
||||
onChange: () => onServiceChange?.(),
|
||||
},
|
||||
serviceSettings: {
|
||||
type: OptionType.COMPONENT,
|
||||
description: "Service configuration.",
|
||||
component: SettingsPanel,
|
||||
},
|
||||
|
||||
// Per-service enable toggles
|
||||
abs_enabled: {
|
||||
description: "Enable AudioBookShelf presence.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
onChange: () => onServiceChange?.(),
|
||||
},
|
||||
tosu_enabled: {
|
||||
description: "Enable osu! (tosu) presence.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
onChange: () => onServiceChange?.(),
|
||||
},
|
||||
sfm_enabled: {
|
||||
description: "Enable stats.fm presence.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
onChange: () => onServiceChange?.(),
|
||||
},
|
||||
jf_enabled: {
|
||||
description: "Enable Jellyfin presence.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
onChange: () => onServiceChange?.(),
|
||||
},
|
||||
lb_enabled: {
|
||||
description: "Enable ListenBrainz presence.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
onChange: () => onServiceChange?.(),
|
||||
},
|
||||
gr_enabled: {
|
||||
description: "Enable Gensokyo Radio presence.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
onChange: () => onServiceChange?.(),
|
||||
},
|
||||
|
||||
// AudioBookShelf
|
||||
abs_serverUrl: {
|
||||
description: "AudioBookShelf server URL.",
|
||||
type: OptionType.STRING,
|
||||
default: "",
|
||||
hidden: true,
|
||||
},
|
||||
abs_username: {
|
||||
description: "AudioBookShelf username.",
|
||||
type: OptionType.STRING,
|
||||
default: "",
|
||||
hidden: true,
|
||||
},
|
||||
abs_password: {
|
||||
description: "AudioBookShelf password.",
|
||||
type: OptionType.STRING,
|
||||
default: "",
|
||||
hidden: true,
|
||||
},
|
||||
|
||||
// stats.fm
|
||||
sfm_username: {
|
||||
description: "Stats.fm username.",
|
||||
type: OptionType.STRING,
|
||||
default: "",
|
||||
hidden: true,
|
||||
},
|
||||
sfm_shareUsername: {
|
||||
description: "Show link to stats.fm profile.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
},
|
||||
sfm_shareSong: {
|
||||
description: "Show link to song on stats.fm.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
hidden: true,
|
||||
},
|
||||
sfm_hideWithSpotify: {
|
||||
description: "Hide stats.fm presence if Spotify is running.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
},
|
||||
sfm_hideWithExternalRPC: {
|
||||
description: "Hide stats.fm presence if an external RPC is running.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
},
|
||||
sfm_statusName: {
|
||||
description: "Custom status text.",
|
||||
type: OptionType.STRING,
|
||||
default: "Stats.fm",
|
||||
hidden: true,
|
||||
},
|
||||
sfm_nameFormat: {
|
||||
description: "Name format.",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: "Use custom status name", value: NameFormat.StatusName, default: true },
|
||||
{ label: "Use format 'artist - song'", value: NameFormat.ArtistFirst },
|
||||
{ label: "Use format 'song - artist'", value: NameFormat.SongFirst },
|
||||
{ label: "Use artist name only", value: NameFormat.ArtistOnly },
|
||||
{ label: "Use song name only", value: NameFormat.SongOnly },
|
||||
{ label: "Use album name", value: NameFormat.AlbumName },
|
||||
],
|
||||
hidden: true,
|
||||
},
|
||||
sfm_useListeningStatus: {
|
||||
description: "Show listening status.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
hidden: true,
|
||||
},
|
||||
sfm_missingArt: {
|
||||
description: "Fallback when art is missing.",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: "Use large Stats.fm logo", value: "StatsFmLogo", default: true },
|
||||
{ label: "Use generic placeholder", value: "placeholder" },
|
||||
],
|
||||
hidden: true,
|
||||
},
|
||||
sfm_showLogo: {
|
||||
description: "Show Stats.fm logo next to album art.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
hidden: true,
|
||||
},
|
||||
sfm_alwaysHideArt: {
|
||||
description: "Disable downloading album art.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
},
|
||||
|
||||
// Jellyfin
|
||||
jf_serverUrl: {
|
||||
description: "Jellyfin server URL.",
|
||||
type: OptionType.STRING,
|
||||
default: "",
|
||||
hidden: true,
|
||||
},
|
||||
jf_apiKey: {
|
||||
description: "Jellyfin API key.",
|
||||
type: OptionType.STRING,
|
||||
default: "",
|
||||
hidden: true,
|
||||
},
|
||||
jf_userId: {
|
||||
description: "Jellyfin user ID.",
|
||||
type: OptionType.STRING,
|
||||
default: "",
|
||||
hidden: true,
|
||||
},
|
||||
jf_nameDisplay: {
|
||||
description: "Name display format.",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: "Series/Movie Name", value: "default", default: true },
|
||||
{ label: "Series - Episode/Track/Movie Name", value: "full" },
|
||||
{ label: "Custom", value: "custom" },
|
||||
],
|
||||
hidden: true,
|
||||
},
|
||||
jf_customName: {
|
||||
description: "Custom name template.",
|
||||
type: OptionType.STRING,
|
||||
default: "",
|
||||
hidden: true,
|
||||
},
|
||||
jf_coverType: {
|
||||
description: "Cover type for TV shows.",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: "Series Cover", value: "series", default: true },
|
||||
{ label: "Episode Cover", value: "episode" },
|
||||
],
|
||||
hidden: true,
|
||||
},
|
||||
jf_episodeFormat: {
|
||||
description: "Episode number format.",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: "S01E01", value: "long", default: true },
|
||||
{ label: "1x01", value: "short" },
|
||||
{ label: "Season 1 Episode 1", value: "fulltext" },
|
||||
],
|
||||
hidden: true,
|
||||
},
|
||||
jf_showEpisodeName: {
|
||||
description: "Show episode name after season/episode info.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
},
|
||||
jf_overrideType: {
|
||||
description: "Override rich presence type.",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: "Off", value: "off", default: true },
|
||||
{ label: "Listening", value: "2" },
|
||||
{ label: "Playing", value: "0" },
|
||||
{ label: "Streaming", value: "1" },
|
||||
{ label: "Watching", value: "3" },
|
||||
],
|
||||
hidden: true,
|
||||
},
|
||||
jf_showPausedState: {
|
||||
description: "Show presence when media is paused.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
hidden: true,
|
||||
},
|
||||
jf_privacyMode: {
|
||||
description: "Hide media details.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
},
|
||||
|
||||
// ListenBrainz
|
||||
lb_username: {
|
||||
description: "ListenBrainz username.",
|
||||
type: OptionType.STRING,
|
||||
default: "",
|
||||
hidden: true,
|
||||
},
|
||||
lb_mbContact: {
|
||||
description: "MusicBrainz contact for user agent.",
|
||||
type: OptionType.STRING,
|
||||
default: "",
|
||||
hidden: true,
|
||||
},
|
||||
lb_shareUsername: {
|
||||
description: "Show link to ListenBrainz profile.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
},
|
||||
lb_shareSong: {
|
||||
description: "Show link to song on ListenBrainz.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
hidden: true,
|
||||
},
|
||||
lb_hideWithSpotify: {
|
||||
description: "Hide presence if Spotify is running.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
hidden: true,
|
||||
},
|
||||
lb_hideWithActivity: {
|
||||
description: "Hide presence if any other presence exists.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
},
|
||||
lb_useTimeBar: {
|
||||
description: "Use track duration to display a time bar.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
hidden: true,
|
||||
},
|
||||
lb_statusName: {
|
||||
description: "Custom status text.",
|
||||
type: OptionType.STRING,
|
||||
default: "some music",
|
||||
hidden: true,
|
||||
},
|
||||
lb_nameFormat: {
|
||||
description: "Name format.",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: "Use custom status name", value: NameFormat.StatusName, default: true },
|
||||
{ label: "Use format 'artist - song'", value: NameFormat.ArtistFirst },
|
||||
{ label: "Use format 'song - artist'", value: NameFormat.SongFirst },
|
||||
{ label: "Use artist name only", value: NameFormat.ArtistOnly },
|
||||
{ label: "Use song name only", value: NameFormat.SongOnly },
|
||||
{ label: "Use album name", value: NameFormat.AlbumName },
|
||||
],
|
||||
hidden: true,
|
||||
},
|
||||
lb_useListeningStatus: {
|
||||
description: "Show listening status.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
hidden: true,
|
||||
},
|
||||
lb_missingArt: {
|
||||
description: "Fallback when art is missing.",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: "Use large ListenBrainz logo", value: "listenbrainzLogo", default: true },
|
||||
{ label: "Use generic placeholder", value: "placeholder" },
|
||||
],
|
||||
hidden: true,
|
||||
},
|
||||
lb_useLogo: {
|
||||
description: "Show ListenBrainz logo on album art.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
hidden: true,
|
||||
},
|
||||
|
||||
// Gensokyo Radio
|
||||
gr_refreshInterval: {
|
||||
description: "Refresh interval in seconds.",
|
||||
type: OptionType.SLIDER,
|
||||
markers: [1, 2, 2.5, 3, 5, 10, 15],
|
||||
default: 15,
|
||||
hidden: true,
|
||||
},
|
||||
});
|
||||
|
||||
export type SettingsStore = typeof settings["store"];
|
||||
32
src/equicordplugins/richPresence/types/audiobookshelf.ts
Normal file
32
src/equicordplugins/richPresence/types/audiobookshelf.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export interface AbsSession {
|
||||
updatedAt?: number;
|
||||
isFinished?: boolean;
|
||||
mediaMetadata?: {
|
||||
title?: string;
|
||||
author?: string;
|
||||
publisher?: string;
|
||||
authors?: { name?: string; }[];
|
||||
series?: { name?: string; }[];
|
||||
};
|
||||
mediaType?: string;
|
||||
duration?: number;
|
||||
currentTime?: number;
|
||||
libraryItemId?: string;
|
||||
}
|
||||
|
||||
export interface AbsMediaData {
|
||||
name: string;
|
||||
type: string;
|
||||
author?: string;
|
||||
series?: string;
|
||||
duration?: number;
|
||||
currentTime?: number;
|
||||
imageUrl?: string;
|
||||
isFinished?: boolean;
|
||||
}
|
||||
14
src/equicordplugins/richPresence/types/gensokyoRadio.ts
Normal file
14
src/equicordplugins/richPresence/types/gensokyoRadio.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export interface GrTrackData {
|
||||
title: string;
|
||||
album: string;
|
||||
artist: string;
|
||||
artwork: string;
|
||||
position: number;
|
||||
duration: number;
|
||||
}
|
||||
23
src/equicordplugins/richPresence/types/index.ts
Normal file
23
src/equicordplugins/richPresence/types/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export enum ServiceTab {
|
||||
AudioBookShelf = "audiobookshelf",
|
||||
Tosu = "tosu",
|
||||
StatsFm = "statsfm",
|
||||
Jellyfin = "jellyfin",
|
||||
ListenBrainz = "listenbrainz",
|
||||
GensokyoRadio = "gensokyoRadio",
|
||||
}
|
||||
|
||||
export const enum NameFormat {
|
||||
StatusName = "status-name",
|
||||
ArtistFirst = "artist-first",
|
||||
SongFirst = "song-first",
|
||||
ArtistOnly = "artist",
|
||||
SongOnly = "song",
|
||||
AlbumName = "album",
|
||||
}
|
||||
46
src/equicordplugins/richPresence/types/jellyfin.ts
Normal file
46
src/equicordplugins/richPresence/types/jellyfin.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export interface JfSession {
|
||||
UserId: string;
|
||||
NowPlayingItem?: JfNowPlayingItem;
|
||||
PlayState?: {
|
||||
IsPaused?: boolean;
|
||||
PositionTicks?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface JfNowPlayingItem {
|
||||
Name?: string;
|
||||
Type?: string;
|
||||
Id?: string;
|
||||
SeriesId?: string;
|
||||
SeriesName?: string;
|
||||
Artists?: string[];
|
||||
AlbumArtist?: string;
|
||||
Album?: string;
|
||||
ParentIndexNumber?: number;
|
||||
IndexNumber?: number;
|
||||
ProductionYear?: number;
|
||||
RunTimeTicks?: number;
|
||||
ImageTags?: { Primary?: string; };
|
||||
}
|
||||
|
||||
export interface JfMediaData {
|
||||
name: string;
|
||||
type: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
seriesName?: string;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
year?: number;
|
||||
url?: string;
|
||||
imageUrl?: string;
|
||||
duration?: number;
|
||||
position?: number;
|
||||
isPaused?: boolean;
|
||||
}
|
||||
15
src/equicordplugins/richPresence/types/listenbrainz.ts
Normal file
15
src/equicordplugins/richPresence/types/listenbrainz.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export interface LbTrackData {
|
||||
name: string;
|
||||
album: string;
|
||||
artist: string;
|
||||
durationMs?: number;
|
||||
recordingMBID?: string;
|
||||
url: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
56
src/equicordplugins/richPresence/types/statsfm.ts
Normal file
56
src/equicordplugins/richPresence/types/statsfm.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export interface SfmTrackData {
|
||||
name: string;
|
||||
albums: string;
|
||||
artists: string;
|
||||
url: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
interface Albums {
|
||||
id: number;
|
||||
image: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Artists {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface ExternalIds {
|
||||
spotify: string[];
|
||||
appleMusic: string[];
|
||||
}
|
||||
|
||||
interface Track {
|
||||
albums: Albums[];
|
||||
artists: Artists[];
|
||||
durationMs: number;
|
||||
explicit: boolean;
|
||||
externalIds: ExternalIds;
|
||||
id: number;
|
||||
name: string;
|
||||
spotifyPopularity: number;
|
||||
spotifyPreview: string;
|
||||
appleMusicPreview: string;
|
||||
}
|
||||
|
||||
interface Item {
|
||||
date: string;
|
||||
isPlaying: boolean;
|
||||
progressMs: number;
|
||||
deviceName: string;
|
||||
track: Track;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export interface SfmResponse {
|
||||
item: Item;
|
||||
}
|
||||
|
|
@ -407,7 +407,7 @@ export interface Play {
|
|||
accuracy: number;
|
||||
healthBar: HealthBar;
|
||||
hits: Hits;
|
||||
hitErrorArray: any[];
|
||||
hitErrorArray: number[];
|
||||
combo: Combo;
|
||||
mods: NumberName;
|
||||
rank: Rank;
|
||||
|
|
@ -1,371 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { getUserSettingLazy } from "@api/UserSettings";
|
||||
import { HeadingSecondary } from "@components/Heading";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { ActivityFlags, ActivityType } from "@vencord/discord-types/enums";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { ApplicationAssetUtils, FluxDispatcher } from "@webpack/common";
|
||||
|
||||
interface ActivityAssets {
|
||||
large_image?: string;
|
||||
large_text?: string;
|
||||
small_image?: string;
|
||||
small_text?: string;
|
||||
}
|
||||
|
||||
interface ActivityButton {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Activity {
|
||||
state: string;
|
||||
details?: string;
|
||||
timestamps?: {
|
||||
start?: number;
|
||||
};
|
||||
assets?: ActivityAssets;
|
||||
buttons?: Array<string>;
|
||||
name: string;
|
||||
application_id: string;
|
||||
metadata?: {
|
||||
button_urls?: Array<string>;
|
||||
};
|
||||
type: number;
|
||||
flags: number;
|
||||
}
|
||||
|
||||
interface TrackData {
|
||||
name: string;
|
||||
albums: string;
|
||||
artists: string;
|
||||
url: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
const enum NameFormat {
|
||||
StatusName = "status-name",
|
||||
ArtistFirst = "artist-first",
|
||||
SongFirst = "song-first",
|
||||
ArtistOnly = "artist",
|
||||
SongOnly = "song",
|
||||
albumsName = "albums"
|
||||
}
|
||||
|
||||
interface Albums {
|
||||
id: number;
|
||||
image: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Artists {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface ExternalIds {
|
||||
spotify: string[];
|
||||
appleMusic: string[];
|
||||
}
|
||||
|
||||
interface Track {
|
||||
albums: Albums[];
|
||||
artists: Artists[];
|
||||
durationMs: number;
|
||||
explicit: boolean;
|
||||
externalIds: ExternalIds;
|
||||
id: number;
|
||||
name: string;
|
||||
spotifyPopularity: number;
|
||||
spotifyPreview: string;
|
||||
appleMusicPreview: string;
|
||||
}
|
||||
|
||||
interface Item {
|
||||
date: string;
|
||||
isPlaying: boolean;
|
||||
progressMs: number;
|
||||
deviceName: string;
|
||||
track: Track;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
interface SFMR {
|
||||
item: Item;
|
||||
}
|
||||
|
||||
const ShowCurrentGame = getUserSettingLazy<boolean>("status", "showCurrentGame")!;
|
||||
|
||||
const applicationId = "1325126169179197500";
|
||||
const placeholderId = "2a96cbd8b46e442fc41c2b86b821562f";
|
||||
|
||||
const logger = new Logger("StatsfmPresence");
|
||||
|
||||
const PresenceStore = findByPropsLazy("getLocalPresence");
|
||||
|
||||
let updateInterval: NodeJS.Timeout | undefined;
|
||||
|
||||
async function getApplicationAsset(key: string): Promise<string> {
|
||||
return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0];
|
||||
}
|
||||
|
||||
function setActivity(activity: Activity | null) {
|
||||
FluxDispatcher.dispatch({
|
||||
type: "LOCAL_ACTIVITY_UPDATE",
|
||||
activity,
|
||||
socketId: "StatsfmPresence",
|
||||
});
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
username: {
|
||||
description: "Stats.fm username",
|
||||
type: OptionType.STRING,
|
||||
},
|
||||
shareUsername: {
|
||||
description: "Show link to stats.fm profile",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
},
|
||||
shareSong: {
|
||||
description: "Show link to song on stats.fm",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
hideWithSpotify: {
|
||||
description: "Hide stats.fm presence if spotify is running",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
},
|
||||
hideWithExternalRPC: {
|
||||
description: "Hides stats.fm presence if an external RPC is running",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
},
|
||||
statusName: {
|
||||
description: "Custom status text",
|
||||
type: OptionType.STRING,
|
||||
default: "Stats.fm",
|
||||
},
|
||||
nameFormat: {
|
||||
description: "Show name of song and artist in status name",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{
|
||||
label: "Use custom status name",
|
||||
value: NameFormat.StatusName,
|
||||
default: true
|
||||
},
|
||||
{
|
||||
label: "Use format 'artist - song'",
|
||||
value: NameFormat.ArtistFirst
|
||||
},
|
||||
{
|
||||
label: "Use format 'song - artist'",
|
||||
value: NameFormat.SongFirst
|
||||
},
|
||||
{
|
||||
label: "Use artist name only",
|
||||
value: NameFormat.ArtistOnly
|
||||
},
|
||||
{
|
||||
label: "Use song name only",
|
||||
value: NameFormat.SongOnly
|
||||
},
|
||||
{
|
||||
label: "Use albums name (falls back to custom status text if song has no albums)",
|
||||
value: NameFormat.albumsName
|
||||
}
|
||||
],
|
||||
},
|
||||
useListeningStatus: {
|
||||
description: 'Show "Listening to" status instead of "Playing"',
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
missingArt: {
|
||||
description: "When albums or albums art is missing",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{
|
||||
label: "Use large Stats.fm logo",
|
||||
value: "StatsFmLogo",
|
||||
default: true
|
||||
},
|
||||
{
|
||||
label: "Use generic placeholder",
|
||||
value: "placeholder"
|
||||
}
|
||||
],
|
||||
},
|
||||
showStatsFmLogo: {
|
||||
description: "Show the Stats.fm next to the albums cover",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
alwaysHideArt: {
|
||||
description: "Disable downloading album art",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "StatsfmPresence",
|
||||
description: "Statsfm presence to track your music",
|
||||
authors: [EquicordDevs.Crxa],
|
||||
|
||||
settingsAboutComponent: () => (
|
||||
<>
|
||||
<HeadingSecondary>How does this work?</HeadingSecondary>
|
||||
<Paragraph>
|
||||
Hey this is just here to explain how this works. By putting your stats.fm username in the settings, it will show what you're currently listening to on your discord profile. (this doesnt require an api but requires you to have your listening history public)
|
||||
</Paragraph>
|
||||
</>
|
||||
),
|
||||
|
||||
settings,
|
||||
|
||||
start() {
|
||||
this.updatePresence();
|
||||
updateInterval = setInterval(() => { this.updatePresence(); }, 16000);
|
||||
},
|
||||
|
||||
stop() {
|
||||
clearInterval(updateInterval);
|
||||
updateInterval = undefined;
|
||||
},
|
||||
|
||||
async fetchTrackData(): Promise<TrackData | null> {
|
||||
if (!settings.store.username)
|
||||
return null;
|
||||
|
||||
try {
|
||||
|
||||
const res = await fetch(`https://api.stats.fm/api/v1/users/${settings.store.username}/streams/current`);
|
||||
if (!res.ok) throw `${res.status} ${res.statusText}`;
|
||||
|
||||
const json = await res.json() as SFMR;
|
||||
if (!json.item) {
|
||||
logger.error("Error from Stats.fm API", json);
|
||||
return null;
|
||||
}
|
||||
|
||||
const trackData = json.item.track;
|
||||
if (!trackData) return null;
|
||||
return {
|
||||
name: trackData.name || "Unknown",
|
||||
albums: trackData.albums.map(a => a.name).join(", ") ?? "Unknown",
|
||||
artists: trackData.artists[0].name ?? "Unknown",
|
||||
url: `https://stats.fm/track/${trackData.id}`, // https://stats.fm/track/665906 / https://twirl.cx/dj2gL.png reminder of what the id looks like to fetch track
|
||||
imageUrl: trackData.albums[0].image
|
||||
};
|
||||
} catch (e) {
|
||||
logger.error("Failed to query Stats.fm API", e);
|
||||
// will clear the rich presence if API fails
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async updatePresence() {
|
||||
setActivity(await this.getActivity());
|
||||
},
|
||||
|
||||
getLargeImage(track: TrackData): string | undefined {
|
||||
if (!settings.store.alwaysHideArt && track.imageUrl && !track.imageUrl.includes(placeholderId))
|
||||
return track.imageUrl;
|
||||
|
||||
if (settings.store.missingArt === "placeholder")
|
||||
return "placeholder";
|
||||
},
|
||||
|
||||
async getActivity(): Promise<Activity | null> {
|
||||
if (settings.store.hideWithExternalRPC) {
|
||||
if (PresenceStore.getActivities().some(a => a.application_id !== applicationId)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.store.hideWithSpotify) {
|
||||
if (PresenceStore.getActivities().some(a => a.type === ActivityType.LISTENING && a.application_id !== applicationId)) {
|
||||
// there is already music status because of Spotify or richerCider (probably more)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const trackData = await this.fetchTrackData();
|
||||
if (!trackData) return null;
|
||||
|
||||
const largeImage = this.getLargeImage(trackData);
|
||||
const assets: ActivityAssets = largeImage ?
|
||||
{
|
||||
large_image: await getApplicationAsset(largeImage),
|
||||
large_text: trackData.albums || undefined,
|
||||
...(settings.store.showStatsFmLogo && {
|
||||
small_image: await getApplicationAsset("statsfm-large"),
|
||||
small_text: "Stats.fm"
|
||||
}),
|
||||
} : {
|
||||
large_image: await getApplicationAsset("statsfm-large"),
|
||||
large_text: trackData.albums || undefined,
|
||||
};
|
||||
|
||||
const buttons: ActivityButton[] = [];
|
||||
|
||||
if (settings.store.shareUsername)
|
||||
buttons.push({
|
||||
label: "Stats.fm Profile",
|
||||
url: `https://stats.fm/${settings.store.username}`,
|
||||
});
|
||||
|
||||
if (settings.store.shareSong)
|
||||
buttons.push({
|
||||
label: "View Song",
|
||||
url: trackData.url,
|
||||
});
|
||||
|
||||
const statusName = (() => {
|
||||
switch (settings.store.nameFormat) {
|
||||
case NameFormat.ArtistFirst:
|
||||
return trackData.artists + " - " + trackData.name;
|
||||
case NameFormat.SongFirst:
|
||||
return trackData.name + " - " + trackData.artists;
|
||||
case NameFormat.ArtistOnly:
|
||||
return trackData.artists;
|
||||
case NameFormat.SongOnly:
|
||||
return trackData.name;
|
||||
case NameFormat.albumsName:
|
||||
return trackData.albums || settings.store.statusName;
|
||||
default:
|
||||
return settings.store.statusName;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
application_id: applicationId,
|
||||
name: statusName,
|
||||
|
||||
details: trackData.name,
|
||||
state: trackData.artists,
|
||||
assets,
|
||||
|
||||
buttons: buttons.length ? buttons.map(v => v.label) : undefined,
|
||||
metadata: {
|
||||
button_urls: buttons.map(v => v.url),
|
||||
},
|
||||
|
||||
type: settings.store.useListeningStatus ? ActivityType.LISTENING : ActivityType.PLAYING,
|
||||
flags: ActivityFlags.INSTANCE,
|
||||
};
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue