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,