From 16e882398f61053d44728b1366c557ad6b0ff958 Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Fri, 22 May 2026 20:59:05 +0300 Subject: [PATCH] Settings: Add Discord rich presence --- src/App/App.js | 39 ++++--- src/common/Discord/Discord.tsx | 136 +++++++++++++++++++++++ src/common/Discord/index.ts | 6 + src/common/index.js | 3 + src/core/Error/Error.tsx | 1 - src/core/types/models/Ctx.d.ts | 1 + src/routes/Player/Player.js | 37 +++++- src/routes/Settings/General/General.less | 6 + src/routes/Settings/General/General.tsx | 24 +++- src/services/Discord/Discord.ts | 76 +++++++++++++ src/services/Discord/index.ts | 5 + src/services/ServicesContext/types.d.ts | 1 + src/services/index.js | 2 + 13 files changed, 317 insertions(+), 20 deletions(-) create mode 100644 src/common/Discord/Discord.tsx create mode 100644 src/common/Discord/index.ts create mode 100644 src/services/Discord/Discord.ts create mode 100644 src/services/Discord/index.ts diff --git a/src/App/App.js b/src/App/App.js index 4906a5921..95eefa8b8 100644 --- a/src/App/App.js +++ b/src/App/App.js @@ -5,9 +5,9 @@ const React = require('react'); const { useTranslation } = require('react-i18next'); const { useCore } = require('stremio/core'); const { Router } = require('stremio-router'); -const { Chromecast, ServicesProvider, GamepadProvider } = require('stremio/services'); +const { Chromecast, Discord, ServicesProvider, GamepadProvider } = require('stremio/services'); const { NotFound } = require('stremio/routes'); -const { FullscreenProvider, ToastProvider, TooltipProvider, ShortcutsProvider, CONSTANTS, useBinaryState, useProfile, withCoreSuspender, onFileDrop, usePlatform } = require('stremio/common'); +const { FullscreenProvider, ToastProvider, TooltipProvider, ShortcutsProvider, DiscordProvider, CONSTANTS, useBinaryState, useProfile, withCoreSuspender, onFileDrop, usePlatform } = require('stremio/common'); const ServicesToaster = require('./ServicesToaster'); const DeepLinkHandler = require('./DeepLinkHandler'); const SearchParamsHandler = require('./SearchParamsHandler'); @@ -31,6 +31,7 @@ const App = () => { }, []); const services = React.useMemo(() => { return { + discord: new Discord(), chromecast: new Chromecast(), }; }, []); @@ -99,6 +100,8 @@ const App = () => { }; services.chromecast.on('stateChanged', onChromecastStateChange); services.chromecast.start(); + services.discord.init(shell); + window.services = services; return () => { services.chromecast.stop(); @@ -189,21 +192,23 @@ const App = () => { - { - shortcutModalOpen && - } - { - gamepadModalOpen && - } - - - - - + + { + shortcutModalOpen && + } + { + gamepadModalOpen && + } + + + + + + diff --git a/src/common/Discord/Discord.tsx b/src/common/Discord/Discord.tsx new file mode 100644 index 000000000..886ed0cfb --- /dev/null +++ b/src/common/Discord/Discord.tsx @@ -0,0 +1,136 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useServices } from 'stremio/services'; +import useProfile from '../useProfile'; + +type Activity = { + state: string, + details?: string | null, + image?: string | null, + startTimestamp?: number | null, +}; + +type DiscordContextValue = { + available: boolean, + connected: boolean, + enabled: boolean, + setActivity: (activity: Activity | null) => void, +}; + +const DiscordContext = createContext(null); + +const sameActivity = (first: Activity | null, second: Activity | null) => { + return first?.state === second?.state && + first?.details === second?.details && + first?.image === second?.image && + first?.startTimestamp === second?.startTimestamp; +}; + +type Props = { + children: React.ReactNode, +}; + +const DiscordProvider = ({ children }: Props) => { + const { discord } = useServices(); + const profile = useProfile(); + const enabled = profile.settings?.discordRpcEnabled === true; + const [available, setAvailable] = useState(discord?.available === true); + const [connected, setConnected] = useState(false); + const [activity, setActivityState] = useState(null); + const sentActivity = useRef(null); + const connectRequested = useRef(false); + + useEffect(() => { + if (!discord) return; + + const onStatusChanged = (isConnected: boolean) => { + connectRequested.current = false; + setConnected(isConnected); + }; + const onAvailabilityChanged = (isAvailable: boolean) => { + setAvailable(isAvailable); + }; + + discord.on('statusChanged', onStatusChanged); + discord.on('availabilityChanged', onAvailabilityChanged); + setAvailable(discord.available === true); + + return () => { + discord.off('statusChanged', onStatusChanged); + discord.off('availabilityChanged', onAvailabilityChanged); + }; + }, [discord]); + + useEffect(() => { + if (!discord || !available) { + connectRequested.current = false; + setConnected(false); + sentActivity.current = null; + return; + } + + if (enabled) { + if (!connected && !connectRequested.current) { + connectRequested.current = true; + discord.connect(); + } + } else { + connectRequested.current = false; + if (connected) { + discord.disconnect(); + } + sentActivity.current = null; + } + }, [available, connected, discord, enabled]); + + useEffect(() => { + if (!discord || !available || !enabled || !connected) return; + + if (activity === null) { + if (sentActivity.current !== null) { + discord.clearActivity(); + sentActivity.current = null; + } + return; + } + + if (sameActivity(sentActivity.current, activity)) return; + + discord.setActivity( + activity.state, + activity.details || '', + activity.image || null, + activity.startTimestamp || null + ); + sentActivity.current = activity; + }, [activity, available, connected, discord, enabled]); + + const setActivity = useCallback((nextActivity: Activity | null) => { + setActivityState((currentActivity) => sameActivity(currentActivity, nextActivity) ? currentActivity : nextActivity); + }, []); + + const value = useMemo(() => ({ + available, + connected, + enabled, + setActivity, + }), [available, connected, enabled, setActivity]); + + return ( + + {children} + + ); +}; + +const useDiscord = () => { + const value = useContext(DiscordContext); + if (value === null) { + throw new Error('useDiscord must be used inside DiscordProvider'); + } + return value; +}; + +export { + DiscordProvider, + useDiscord, +}; diff --git a/src/common/Discord/index.ts b/src/common/Discord/index.ts new file mode 100644 index 000000000..f16f4bc47 --- /dev/null +++ b/src/common/Discord/index.ts @@ -0,0 +1,6 @@ +import { DiscordProvider, useDiscord } from './Discord'; + +export { + DiscordProvider, + useDiscord, +}; diff --git a/src/common/index.js b/src/common/index.js index b99b88c2f..0c7721f8a 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -6,6 +6,7 @@ const { PlatformProvider, usePlatform } = require('./Platform'); const { ToastProvider, useToast } = require('./Toast'); const { TooltipProvider, Tooltip } = require('./Tooltips'); const { ShortcutsProvider, useShortcuts, onShortcut } = require('./Shortcuts'); +const { DiscordProvider, useDiscord } = require('./Discord'); const CONSTANTS = require('./CONSTANTS'); const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender'); const getVisibleChildrenRange = require('./getVisibleChildrenRange'); @@ -44,6 +45,8 @@ module.exports = { useToast, TooltipProvider, Tooltip, + DiscordProvider, + useDiscord, CONSTANTS, withCoreSuspender, useCoreSuspender, diff --git a/src/core/Error/Error.tsx b/src/core/Error/Error.tsx index 36c9d1811..9aedf2821 100644 --- a/src/core/Error/Error.tsx +++ b/src/core/Error/Error.tsx @@ -47,4 +47,3 @@ const Error = () => { }; export default Error; - diff --git a/src/core/types/models/Ctx.d.ts b/src/core/types/models/Ctx.d.ts index 4bdb22ec5..8d7e6cc9c 100644 --- a/src/core/types/models/Ctx.d.ts +++ b/src/core/types/models/Ctx.d.ts @@ -18,6 +18,7 @@ type Settings = { audioPassthrough: boolean, autoFrameRateMatching: boolean, bingeWatching: boolean, + discordRpcEnabled: boolean, hardwareDecoding: boolean, videoMode: string | null, escExitFullscreen: boolean, diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index 67baeceb2..d907ee57a 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -10,7 +10,7 @@ const { useRouteFocused } = require('stremio-router'); const { useCore } = require('stremio/core'); const { useServices, useGamepad } = require('stremio/services'); const { useContentGamepadNavigation } = require('stremio/services/GamepadNavigation'); -const { useSettings, useProfile, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, usePlatform, onShortcut } = require('stremio/common'); +const { useSettings, useProfile, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, usePlatform, onShortcut, useDiscord } = require('stremio/common'); const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components'); const { default: Buffering } = require('./Buffering'); const VolumeChangeIndicator = require('./VolumeChangeIndicator'); @@ -55,6 +55,8 @@ const Player = ({ urlParams, queryParams }) => { const routeFocused = useRouteFocused(); const platform = usePlatform(); const toast = useToast(); + const discord = useDiscord(); + const discordStartTimestamp = React.useRef(null); const [seeking, setSeeking] = React.useState(false); @@ -540,6 +542,39 @@ const Player = ({ urlParams, queryParams }) => { } }, [settings.pauseOnMinimize, platform.shell.state.windowClosed, platform.shell.state.windowHidden]); + React.useEffect(() => { + if (video.state.stream === null || typeof player?.title !== 'string') { + discordStartTimestamp.current = null; + discord.setActivity(null); + return; + } + + if (video.state.paused) { + discordStartTimestamp.current = null; + } else if (typeof video.state.time === 'number') { + const startTimestamp = Math.floor((Date.now() / 1000) - video.state.time); + if ( + discordStartTimestamp.current === null || + Math.abs(discordStartTimestamp.current - startTimestamp) > 5 + ) { + discordStartTimestamp.current = startTimestamp; + } + } + + discord.setActivity({ + state: video.state.paused ? 'Paused' : 'Watching', + details: player.title, + image: player.metaItem?.poster || null, + startTimestamp: video.state.paused ? null : discordStartTimestamp.current, + }); + }, [discord.setActivity, player?.title, player.metaItem?.poster, video.state.paused, video.state.stream, video.state.time]); + + React.useEffect(() => { + return () => { + discord.setActivity(null); + }; + }, [discord.setActivity]); + useMediaSession(video.state, player, fullscreen, onPlayRequested, onPauseRequested, onNextVideoRequested); React.useEffect(() => { diff --git a/src/routes/Settings/General/General.less b/src/routes/Settings/General/General.less index 8c253dcff..6f8ce9bfb 100644 --- a/src/routes/Settings/General/General.less +++ b/src/routes/Settings/General/General.less @@ -9,3 +9,9 @@ color: var(--color-trakt) !important; } } + +.discord-container { + .option-icon { + color: #5865f2 !important; + } +} diff --git a/src/routes/Settings/General/General.tsx b/src/routes/Settings/General/General.tsx index 238206bbc..f69e04c93 100644 --- a/src/routes/Settings/General/General.tsx +++ b/src/routes/Settings/General/General.tsx @@ -2,7 +2,7 @@ import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 're import { useTranslation } from 'react-i18next'; import { useCore } from 'stremio/core'; import { Button } from 'stremio/components'; -import { usePlatform, useToast } from 'stremio/common'; +import { usePlatform, useToast, useDiscord } from 'stremio/common'; import { Section, Option, Link } from '../components'; import User from './User'; import useDataExport from './useDataExport'; @@ -17,6 +17,7 @@ const General = forwardRef(({ profile }: Props, ref) => { const core = useCore(); const platform = usePlatform(); const toast = useToast(); + const discord = useDiscord(); const [dataExport, loadDataExport] = useDataExport(); const [traktAuthStarted, setTraktAuthStarted] = useState(false); @@ -61,6 +62,19 @@ const General = forwardRef(({ profile }: Props, ref) => { } }, [isTraktAuthenticated, profile.auth]); + const onToggleDiscord = useCallback(() => { + core.transport.dispatch({ + action: 'Ctx', + args: { + action: 'UpdateSettings', + args: { + ...profile.settings, + discordRpcEnabled: !profile.settings.discordRpcEnabled + } + } + }); + }, [profile.settings]); + useEffect(() => { if (dataExport.exportUrl) { platform.openExternal(dataExport.exportUrl); @@ -134,6 +148,14 @@ const General = forwardRef(({ profile }: Props, ref) => { {isTraktAuthenticated ? t('LOG_OUT') : t('SETTINGS_TRAKT_AUTHENTICATE')} + { + discord.available && + + } ; }); diff --git a/src/services/Discord/Discord.ts b/src/services/Discord/Discord.ts new file mode 100644 index 000000000..e790651a7 --- /dev/null +++ b/src/services/Discord/Discord.ts @@ -0,0 +1,76 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import EventEmitter from 'eventemitter3'; + +type DiscordStatusData = { + connected: boolean; +}; + +class Discord { + private events: EventEmitter; + private shell: any; + + constructor() { + this.events = new EventEmitter(); + this.shell = null; + } + + init(shellService: any): void { + this.shell = shellService; + + if (this.shell) { + this.shell.on('stateChanged', () => { + this.events.emit('availabilityChanged', this.available); + }); + } + + if (this.shell) { + this.shell.on('discord-status', (data: DiscordStatusData) => { + this.events.emit('statusChanged', data.connected); + }); + } + } + + connect(): void { + if (this.shell && this.shell.active) { + this.shell.send('discord-connect', {}); + } + } + + disconnect(): void { + if (this.shell && this.shell.active) { + this.shell.send('discord-disconnect', {}); + } + } + + setActivity(state: string, details: string, image?: string | null, startTimestamp?: number | null): void { + if (this.shell && this.shell.active) { + this.shell.send('discord-set-activity', { + state, + details, + image: image || null, + startTimestamp: startTimestamp || null + }); + } + } + + clearActivity(): void { + if (this.shell && this.shell.active) { + this.shell.send('discord-clear-activity', {}); + } + } + + get available(): boolean { + return this.shell && this.shell.active; + } + + on(name: string, listener: (data: any) => void): void { + this.events.on(name, listener); + } + + off(name: string, listener: (data: any) => void): void { + this.events.off(name, listener); + } +} + +export default Discord; diff --git a/src/services/Discord/index.ts b/src/services/Discord/index.ts new file mode 100644 index 000000000..ddcd51cfc --- /dev/null +++ b/src/services/Discord/index.ts @@ -0,0 +1,5 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import Discord from './Discord'; + +export default Discord; diff --git a/src/services/ServicesContext/types.d.ts b/src/services/ServicesContext/types.d.ts index 3860d3a03..1e4f5d0c4 100644 --- a/src/services/ServicesContext/types.d.ts +++ b/src/services/ServicesContext/types.d.ts @@ -1,3 +1,4 @@ type ServicesContext = { chromecast: any, + discord: any, }; diff --git a/src/services/index.js b/src/services/index.js index 6fc75c3bf..ceaa8c419 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -1,11 +1,13 @@ // Copyright (C) 2017-2023 Smart code 203358507 const Chromecast = require('./Chromecast'); +const Discord = require('./Discord'); const { ServicesProvider, useServices } = require('./ServicesContext'); const { GamepadProvider, useGamepad } = require('./GamepadContext'); module.exports = { Chromecast, + Discord, ServicesProvider, useServices, GamepadProvider,