Add discord rich presence (#72)

This commit is contained in:
jeffvli 2023-10-23 06:58:39 -07:00
parent 2664a80851
commit 244c00c4c6
12 changed files with 391 additions and 7 deletions

62
package-lock.json generated
View file

@ -27,6 +27,7 @@
"@tanstack/react-query-devtools": "^4.32.1", "@tanstack/react-query-devtools": "^4.32.1",
"@tanstack/react-query-persist-client": "^4.32.1", "@tanstack/react-query-persist-client": "^4.32.1",
"@ts-rest/core": "^3.23.0", "@ts-rest/core": "^3.23.0",
"@xhayper/discord-rpc": "^1.0.24",
"axios": "^1.4.0", "axios": "^1.4.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"cmdk": "^0.2.0", "cmdk": "^0.2.0",
@ -5613,6 +5614,38 @@
} }
} }
}, },
"node_modules/@xhayper/discord-rpc": {
"version": "1.0.24",
"resolved": "https://registry.npmjs.org/@xhayper/discord-rpc/-/discord-rpc-1.0.24.tgz",
"integrity": "sha512-gzC8OaOSz7cGALSHyyq6nANQvBfyfntbSq+Qh+cNanoKX8ybOj+jWKmDP6PbLVDWoBftTU3JYsWXrLml2df2Hw==",
"dependencies": {
"axios": "^1.5.1",
"ws": "^8.14.2"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/@xhayper/discord-rpc/node_modules/ws": {
"version": "8.14.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz",
"integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@xmldom/xmldom": { "node_modules/@xmldom/xmldom": {
"version": "0.8.10", "version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
@ -6292,9 +6325,9 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.4.0", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz",
"integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.0", "follow-redirects": "^1.15.0",
"form-data": "^4.0.0", "form-data": "^4.0.0",
@ -25433,6 +25466,23 @@
"dev": true, "dev": true,
"requires": {} "requires": {}
}, },
"@xhayper/discord-rpc": {
"version": "1.0.24",
"resolved": "https://registry.npmjs.org/@xhayper/discord-rpc/-/discord-rpc-1.0.24.tgz",
"integrity": "sha512-gzC8OaOSz7cGALSHyyq6nANQvBfyfntbSq+Qh+cNanoKX8ybOj+jWKmDP6PbLVDWoBftTU3JYsWXrLml2df2Hw==",
"requires": {
"axios": "^1.5.1",
"ws": "^8.14.2"
},
"dependencies": {
"ws": {
"version": "8.14.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz",
"integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==",
"requires": {}
}
}
},
"@xmldom/xmldom": { "@xmldom/xmldom": {
"version": "0.8.10", "version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
@ -25951,9 +26001,9 @@
"dev": true "dev": true
}, },
"axios": { "axios": {
"version": "1.4.0", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz",
"integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==",
"requires": { "requires": {
"follow-redirects": "^1.15.0", "follow-redirects": "^1.15.0",
"form-data": "^4.0.0", "form-data": "^4.0.0",

View file

@ -273,6 +273,7 @@
"@tanstack/react-query-devtools": "^4.32.1", "@tanstack/react-query-devtools": "^4.32.1",
"@tanstack/react-query-persist-client": "^4.32.1", "@tanstack/react-query-persist-client": "^4.32.1",
"@ts-rest/core": "^3.23.0", "@ts-rest/core": "^3.23.0",
"@xhayper/discord-rpc": "^1.0.24",
"axios": "^1.4.0", "axios": "^1.4.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"cmdk": "^0.2.0", "cmdk": "^0.2.0",

View file

@ -0,0 +1,63 @@
import { Client, SetActivity } from '@xhayper/discord-rpc';
import { ipcMain } from 'electron';
const FEISHIN_DISCORD_APPLICATION_ID = '1165957668758900787';
let client: Client | null = null;
const createClient = (clientId?: string) => {
client = new Client({
clientId: clientId || FEISHIN_DISCORD_APPLICATION_ID,
});
client.login();
return client;
};
const setActivity = (activity: SetActivity) => {
if (client) {
client.user?.setActivity({
...activity,
});
}
};
const clearActivity = () => {
if (client) {
client.user?.clearActivity();
}
};
const quit = () => {
if (client) {
client?.destroy();
}
};
ipcMain.handle('discord-rpc-initialize', (_event, clientId?: string) => {
createClient(clientId);
});
ipcMain.handle('discord-rpc-set-activity', (_event, activity: SetActivity) => {
if (client) {
setActivity(activity);
}
});
ipcMain.handle('discord-rpc-clear-activity', () => {
if (client) {
clearActivity();
}
});
ipcMain.handle('discord-rpc-quit', () => {
quit();
});
export const discordRpc = {
clearActivity,
createClient,
quit,
setActivity,
};

View file

@ -2,3 +2,4 @@ import './lyrics';
import './player'; import './player';
import './remote'; import './remote';
import './settings'; import './settings';
import './discord-rpc';

View file

@ -1,5 +1,6 @@
import { contextBridge } from 'electron'; import { contextBridge } from 'electron';
import { browser } from './preload/browser'; import { browser } from './preload/browser';
import { discordRpc } from './preload/discord-rpc';
import { ipc } from './preload/ipc'; import { ipc } from './preload/ipc';
import { localSettings } from './preload/local-settings'; import { localSettings } from './preload/local-settings';
import { lyrics } from './preload/lyrics'; import { lyrics } from './preload/lyrics';
@ -10,6 +11,7 @@ import { utils } from './preload/utils';
contextBridge.exposeInMainWorld('electron', { contextBridge.exposeInMainWorld('electron', {
browser, browser,
discordRpc,
ipc, ipc,
localSettings, localSettings,
lyrics, lyrics,

View file

@ -0,0 +1,28 @@
import { SetActivity } from '@xhayper/discord-rpc';
import { ipcRenderer } from 'electron';
const initialize = (clientId: string) => {
const client = ipcRenderer.invoke('discord-rpc-initialize', clientId);
return client;
};
const clearActivity = () => {
ipcRenderer.invoke('discord-rpc-clear-activity');
};
const setActivity = (activity: SetActivity) => {
ipcRenderer.invoke('discord-rpc-set-activity', activity);
};
const quit = () => {
ipcRenderer.invoke('discord-rpc-quit');
};
export const discordRpc = {
clearActivity,
initialize,
quit,
setActivity,
};
export type DiscordRpc = typeof discordRpc;

View file

@ -25,6 +25,7 @@ import { getMpvProperties } from '/@/renderer/features/settings/components/playb
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store'; import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types'; import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types';
import '@ag-grid-community/styles/ag-grid.css'; import '@ag-grid-community/styles/ag-grid.css';
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]); ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
@ -37,7 +38,6 @@ const remote = isElectron() ? window.electron.remote : null;
export const App = () => { export const App = () => {
const theme = useTheme(); const theme = useTheme();
const contentFont = useSettingsStore((state) => state.general.fontContent);
const accent = useSettingsStore((store) => store.general.accent); const accent = useSettingsStore((store) => store.general.accent);
const { builtIn, custom, system, type } = useSettingsStore((state) => state.font); const { builtIn, custom, system, type } = useSettingsStore((state) => state.font);
const { type: playbackType } = usePlaybackSettings(); const { type: playbackType } = usePlaybackSettings();
@ -46,6 +46,7 @@ export const App = () => {
const { clearQueue, restoreQueue } = useQueueControls(); const { clearQueue, restoreQueue } = useQueueControls();
const remoteSettings = useRemoteSettings(); const remoteSettings = useRemoteSettings();
const textStyleRef = useRef<HTMLStyleElement>(); const textStyleRef = useRef<HTMLStyleElement>();
useDiscordRpc();
useEffect(() => { useEffect(() => {
if (type === FontType.SYSTEM && system) { if (type === FontType.SYSTEM && system) {

View file

@ -0,0 +1,122 @@
/* eslint-disable consistent-return */
import isElectron from 'is-electron';
import { useCallback, useEffect, useRef } from 'react';
import {
useCurrentSong,
useCurrentStatus,
useDiscordSetttings,
usePlayerStore,
} from '/@/renderer/store';
import { SetActivity } from '@xhayper/discord-rpc';
import { PlayerStatus, ServerType } from '/@/renderer/types';
const discordRpc = isElectron() ? window.electron.discordRpc : null;
export const useDiscordRpc = () => {
const intervalRef = useRef(0);
const discordSettings = useDiscordSetttings();
const currentSong = useCurrentSong();
const currentStatus = useCurrentStatus();
const setActivity = useCallback(async () => {
if (!discordSettings.enableIdle && currentStatus === PlayerStatus.PAUSED) {
discordRpc?.clearActivity();
return;
}
const currentTime = usePlayerStore.getState().current.time;
const now = Date.now();
const start = currentTime ? Math.round(now - currentTime * 1000) : null;
const end =
currentSong?.duration && start ? Math.round(start + currentSong.duration) : null;
const artists = currentSong?.artists.map((artist) => artist.name).join(', ');
const activity: SetActivity = {
details: currentSong?.name.padEnd(2, ' ') || 'Idle',
instance: false,
largeImageKey: undefined,
largeImageText: currentSong?.album || 'Unknown album',
smallImageKey: undefined,
smallImageText: currentStatus,
state: artists && `By ${artists}`,
};
if (currentStatus === PlayerStatus.PLAYING) {
if (start && end) {
activity.startTimestamp = start;
activity.endTimestamp = end;
}
activity.smallImageKey = 'playing';
} else {
activity.smallImageKey = 'paused';
}
if (
currentSong?.serverType === ServerType.JELLYFIN &&
discordSettings.showServerImage &&
currentSong?.imageUrl
) {
activity.largeImageKey = currentSong?.imageUrl;
}
// Fall back to default icon if not set
if (!activity.largeImageKey) {
activity.largeImageKey = 'icon';
}
discordRpc?.setActivity(activity);
}, [currentSong, currentStatus, discordSettings.enableIdle, discordSettings.showServerImage]);
useEffect(() => {
const initializeDiscordRpc = async () => {
discordRpc?.initialize(discordSettings.clientId);
};
if (discordSettings.enabled) {
initializeDiscordRpc();
} else {
discordRpc?.quit();
}
return () => {
discordRpc?.quit();
};
}, [discordSettings.clientId, discordSettings.enabled]);
useEffect(() => {
if (discordSettings.enabled) {
let intervalSeconds = discordSettings.updateInterval;
if (intervalSeconds < 15) {
intervalSeconds = 15;
}
intervalRef.current = window.setInterval(setActivity, intervalSeconds * 1000);
return () => clearInterval(intervalRef.current);
}
return () => {};
}, [discordSettings.enabled, discordSettings.updateInterval, setActivity]);
// useEffect(() => {
// console.log(
// 'currentStatus, discordSettings.enableIdle',
// currentStatus,
// discordSettings.enableIdle,
// );
// if (discordSettings.enableIdle === false && currentStatus === PlayerStatus.PAUSED) {
// console.log('removing activity');
// clearActivity();
// clearInterval(intervalRef.current);
// }
// }, [
// clearActivity,
// currentStatus,
// discordSettings.enableIdle,
// discordSettings.enabled,
// setActivity,
// ]);
};

View file

@ -0,0 +1,95 @@
import isElectron from 'is-electron';
import { NumberInput, Switch, TextInput } from '/@/renderer/components';
import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { useDiscordSetttings, useSettingsStoreActions } from '/@/renderer/store';
export const DiscordSettings = () => {
const settings = useDiscordSetttings();
const { setSettings } = useSettingsStoreActions();
const discordOptions: SettingOption[] = [
{
control: (
<Switch
checked={settings.enabled}
onChange={(e) => {
setSettings({
discord: {
...settings,
enabled: e.currentTarget.checked,
},
});
}}
/>
),
description:
'Enable playback status in Discord rich presence. Image keys include: "icon", "playing", and "paused"',
isHidden: !isElectron(),
title: 'Discord rich presence',
},
{
control: (
<TextInput
defaultValue={settings.clientId}
onBlur={(e) => {
setSettings({
discord: {
...settings,
clientId: e.currentTarget.value,
},
});
}}
/>
),
description: 'The Discord application ID (defaults to 1165957668758900787)',
isHidden: !isElectron(),
title: 'Discord application ID',
},
{
control: (
<NumberInput
value={settings.updateInterval}
onChange={(e) => {
let value = e ? Number(e) : 0;
if (value < 15) {
value = 15;
}
setSettings({
discord: {
...settings,
updateInterval: value,
},
});
}}
/>
),
description: 'The time in seconds between each update (minimum 15 seconds)',
isHidden: !isElectron(),
title: 'Rich presence update interval (seconds)',
},
{
control: (
<Switch
checked={settings.enableIdle}
onChange={(e) => {
setSettings({
discord: {
...settings,
enableIdle: e.currentTarget.checked,
},
});
}}
/>
),
description: 'When enabled, the rich presence will update while player is idle',
isHidden: !isElectron(),
title: 'Show rich presence when idle',
},
];
return <SettingsSection options={discordOptions} />;
};

View file

@ -1,12 +1,15 @@
import { Divider, Stack } from '@mantine/core'; import { Divider, Stack } from '@mantine/core';
import { UpdateSettings } from '/@/renderer/features/settings/components/window/update-settings'; import { UpdateSettings } from '/@/renderer/features/settings/components/window/update-settings';
import { WindowSettings } from '/@/renderer/features/settings/components/window/window-settings'; import { WindowSettings } from '/@/renderer/features/settings/components/window/window-settings';
import { DiscordSettings } from '/@/renderer/features/settings/components/window/discord-settings';
export const WindowTab = () => { export const WindowTab = () => {
return ( return (
<Stack spacing="md"> <Stack spacing="md">
<WindowSettings /> <WindowSettings />
<Divider /> <Divider />
<DiscordSettings />
<Divider />
<UpdateSettings /> <UpdateSettings />
</Stack> </Stack>
); );

View file

@ -8,11 +8,13 @@ import { Lyrics } from '/@/main/preload/lyrics';
import { Utils } from '/@/main/preload/utils'; import { Utils } from '/@/main/preload/utils';
import { LocalSettings } from '/@/main/preload/local-settings'; import { LocalSettings } from '/@/main/preload/local-settings';
import { Ipc } from '/@/main/preload/ipc'; import { Ipc } from '/@/main/preload/ipc';
import { DiscordRpc } from '/@/main/preload/discord-rpc';
declare global { declare global {
interface Window { interface Window {
electron: { electron: {
browser: any; browser: any;
discordRpc: DiscordRpc;
ipc?: Ipc; ipc?: Ipc;
ipcRenderer: { ipcRenderer: {
APP_RESTART(): void; APP_RESTART(): void;

View file

@ -112,6 +112,13 @@ export enum BindingActions {
} }
export interface SettingsState { export interface SettingsState {
discord: {
clientId: string;
enableIdle: boolean;
enabled: boolean;
showServerImage: boolean;
updateInterval: number;
};
font: { font: {
builtIn: string; builtIn: string;
custom: string | null; custom: string | null;
@ -216,6 +223,13 @@ const getPlatformDefaultWindowBarStyle = (): Platform => {
const platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle(); const platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle();
const initialState: SettingsState = { const initialState: SettingsState = {
discord: {
clientId: '1165957668758900787',
enableIdle: false,
enabled: false,
showServerImage: false,
updateInterval: 15,
},
font: { font: {
builtIn: 'Inter', builtIn: 'Inter',
custom: null, custom: null,
@ -558,3 +572,5 @@ export const useLyricsSettings = () => useSettingsStore((state) => state.lyrics,
export const useRemoteSettings = () => useSettingsStore((state) => state.remote, shallow); export const useRemoteSettings = () => useSettingsStore((state) => state.remote, shallow);
export const useFontSettings = () => useSettingsStore((state) => state.font, shallow); export const useFontSettings = () => useSettingsStore((state) => state.font, shallow);
export const useDiscordSetttings = () => useSettingsStore((state) => state.discord, shallow);