diff --git a/package-lock.json b/package-lock.json index d80a2202..7d13889f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@tanstack/react-query-devtools": "^4.32.1", "@tanstack/react-query-persist-client": "^4.32.1", "@ts-rest/core": "^3.23.0", + "@xhayper/discord-rpc": "^1.0.24", "axios": "^1.4.0", "clsx": "^2.0.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": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -6292,9 +6325,9 @@ } }, "node_modules/axios": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", - "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", + "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -25433,6 +25466,23 @@ "dev": true, "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": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -25951,9 +26001,9 @@ "dev": true }, "axios": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", - "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", + "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", "requires": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", diff --git a/package.json b/package.json index 890af11f..2c0207cf 100644 --- a/package.json +++ b/package.json @@ -273,6 +273,7 @@ "@tanstack/react-query-devtools": "^4.32.1", "@tanstack/react-query-persist-client": "^4.32.1", "@ts-rest/core": "^3.23.0", + "@xhayper/discord-rpc": "^1.0.24", "axios": "^1.4.0", "clsx": "^2.0.0", "cmdk": "^0.2.0", diff --git a/src/main/features/core/discord-rpc/index.ts b/src/main/features/core/discord-rpc/index.ts new file mode 100644 index 00000000..22737f4f --- /dev/null +++ b/src/main/features/core/discord-rpc/index.ts @@ -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, +}; diff --git a/src/main/features/core/index.ts b/src/main/features/core/index.ts index e2b2455b..1846db0d 100644 --- a/src/main/features/core/index.ts +++ b/src/main/features/core/index.ts @@ -2,3 +2,4 @@ import './lyrics'; import './player'; import './remote'; import './settings'; +import './discord-rpc'; diff --git a/src/main/preload.ts b/src/main/preload.ts index c87e5877..d2183785 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -1,5 +1,6 @@ import { contextBridge } from 'electron'; import { browser } from './preload/browser'; +import { discordRpc } from './preload/discord-rpc'; import { ipc } from './preload/ipc'; import { localSettings } from './preload/local-settings'; import { lyrics } from './preload/lyrics'; @@ -10,6 +11,7 @@ import { utils } from './preload/utils'; contextBridge.exposeInMainWorld('electron', { browser, + discordRpc, ipc, localSettings, lyrics, diff --git a/src/main/preload/discord-rpc.ts b/src/main/preload/discord-rpc.ts new file mode 100644 index 00000000..560a20f2 --- /dev/null +++ b/src/main/preload/discord-rpc.ts @@ -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; diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 4b8a1dd7..39d1cd71 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -25,6 +25,7 @@ import { getMpvProperties } from '/@/renderer/features/settings/components/playb import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store'; import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types'; import '@ag-grid-community/styles/ag-grid.css'; +import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc'; ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]); @@ -37,7 +38,6 @@ const remote = isElectron() ? window.electron.remote : null; export const App = () => { const theme = useTheme(); - const contentFont = useSettingsStore((state) => state.general.fontContent); const accent = useSettingsStore((store) => store.general.accent); const { builtIn, custom, system, type } = useSettingsStore((state) => state.font); const { type: playbackType } = usePlaybackSettings(); @@ -46,6 +46,7 @@ export const App = () => { const { clearQueue, restoreQueue } = useQueueControls(); const remoteSettings = useRemoteSettings(); const textStyleRef = useRef(); + useDiscordRpc(); useEffect(() => { if (type === FontType.SYSTEM && system) { diff --git a/src/renderer/features/discord-rpc/use-discord-rpc.ts b/src/renderer/features/discord-rpc/use-discord-rpc.ts new file mode 100644 index 00000000..19f54b45 --- /dev/null +++ b/src/renderer/features/discord-rpc/use-discord-rpc.ts @@ -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, + // ]); +}; diff --git a/src/renderer/features/settings/components/window/discord-settings.tsx b/src/renderer/features/settings/components/window/discord-settings.tsx new file mode 100644 index 00000000..2481613c --- /dev/null +++ b/src/renderer/features/settings/components/window/discord-settings.tsx @@ -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: ( + { + 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: ( + { + setSettings({ + discord: { + ...settings, + clientId: e.currentTarget.value, + }, + }); + }} + /> + ), + description: 'The Discord application ID (defaults to 1165957668758900787)', + isHidden: !isElectron(), + title: 'Discord application ID', + }, + { + control: ( + { + 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: ( + { + 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 ; +}; diff --git a/src/renderer/features/settings/components/window/window-tab.tsx b/src/renderer/features/settings/components/window/window-tab.tsx index 101ed9f6..ec776211 100644 --- a/src/renderer/features/settings/components/window/window-tab.tsx +++ b/src/renderer/features/settings/components/window/window-tab.tsx @@ -1,12 +1,15 @@ import { Divider, Stack } from '@mantine/core'; import { UpdateSettings } from '/@/renderer/features/settings/components/window/update-settings'; import { WindowSettings } from '/@/renderer/features/settings/components/window/window-settings'; +import { DiscordSettings } from '/@/renderer/features/settings/components/window/discord-settings'; export const WindowTab = () => { return ( + + ); diff --git a/src/renderer/preload.d.ts b/src/renderer/preload.d.ts index 1130909a..c59a59e2 100644 --- a/src/renderer/preload.d.ts +++ b/src/renderer/preload.d.ts @@ -8,11 +8,13 @@ import { Lyrics } from '/@/main/preload/lyrics'; import { Utils } from '/@/main/preload/utils'; import { LocalSettings } from '/@/main/preload/local-settings'; import { Ipc } from '/@/main/preload/ipc'; +import { DiscordRpc } from '/@/main/preload/discord-rpc'; declare global { interface Window { electron: { browser: any; + discordRpc: DiscordRpc; ipc?: Ipc; ipcRenderer: { APP_RESTART(): void; diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 1331f167..51a876e3 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -112,6 +112,13 @@ export enum BindingActions { } export interface SettingsState { + discord: { + clientId: string; + enableIdle: boolean; + enabled: boolean; + showServerImage: boolean; + updateInterval: number; + }; font: { builtIn: string; custom: string | null; @@ -216,6 +223,13 @@ const getPlatformDefaultWindowBarStyle = (): Platform => { const platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle(); const initialState: SettingsState = { + discord: { + clientId: '1165957668758900787', + enableIdle: false, + enabled: false, + showServerImage: false, + updateInterval: 15, + }, font: { builtIn: 'Inter', custom: null, @@ -558,3 +572,5 @@ export const useLyricsSettings = () => useSettingsStore((state) => state.lyrics, export const useRemoteSettings = () => useSettingsStore((state) => state.remote, shallow); export const useFontSettings = () => useSettingsStore((state) => state.font, shallow); + +export const useDiscordSetttings = () => useSettingsStore((state) => state.discord, shallow);