Fix Discord rich presence setting flow

This commit is contained in:
Timothy Z. 2026-05-12 18:23:52 +02:00
parent e3be5a4108
commit 2576d25a12
8 changed files with 184 additions and 142 deletions

View file

@ -6,7 +6,7 @@ const { useTranslation } = require('react-i18next');
const { Router } = require('stremio-router');
const { Core, Shell, Chromecast, Discord, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services');
const { NotFound } = require('stremio/routes');
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, ShortcutsProvider, CONSTANTS, withCoreSuspender, useShell, useBinaryState } = require('stremio/common');
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, ShortcutsProvider, DiscordProvider, CONSTANTS, withCoreSuspender, useShell, useBinaryState } = require('stremio/common');
const ServicesToaster = require('./ServicesToaster');
const DeepLinkHandler = require('./DeepLinkHandler');
const SearchParamsHandler = require('./SearchParamsHandler');
@ -217,18 +217,20 @@ const App = () => {
<TooltipProvider className={styles['tooltip-container']}>
<FileDropProvider className={styles['file-drop-container']}>
<ShortcutsProvider onShortcut={onShortcut}>
{
shortcutModalOpen && <ShortcutsModal onClose={closeShortcutsModal}/>
}
<ServicesToaster />
<DeepLinkHandler />
<SearchParamsHandler />
<UpdaterBanner className={styles['updater-banner-container']} />
<RouterWithProtectedRoutes
className={styles['router']}
viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch}
/>
<DiscordProvider>
{
shortcutModalOpen && <ShortcutsModal onClose={closeShortcutsModal}/>
}
<ServicesToaster />
<DeepLinkHandler />
<SearchParamsHandler />
<UpdaterBanner className={styles['updater-banner-container']} />
<RouterWithProtectedRoutes
className={styles['router']}
viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch}
/>
</DiscordProvider>
</ShortcutsProvider>
</FileDropProvider>
</TooltipProvider>

View file

@ -0,0 +1,129 @@
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<DiscordContextValue | null>(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 = discord?.available === true;
const [connected, setConnected] = useState(false);
const [activity, setActivityState] = useState<Activity | null>(null);
const sentActivity = useRef<Activity | null>(null);
const connectRequested = useRef(false);
useEffect(() => {
if (!discord) return;
const onStatusChanged = (isConnected: boolean) => {
connectRequested.current = false;
setConnected(isConnected);
};
discord.on('statusChanged', onStatusChanged);
return () => {
discord.off('statusChanged', onStatusChanged);
};
}, [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 (
<DiscordContext.Provider value={value}>
{children}
</DiscordContext.Provider>
);
};
const useDiscord = () => {
const value = useContext(DiscordContext);
if (value === null) {
throw new Error('useDiscord must be used inside DiscordProvider');
}
return value;
};
export {
DiscordProvider,
useDiscord,
};

View file

@ -0,0 +1,6 @@
import { DiscordProvider, useDiscord } from './Discord';
export {
DiscordProvider,
useDiscord,
};

View file

@ -5,6 +5,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');
@ -16,7 +17,6 @@ const useAnimationFrame = require('./useAnimationFrame');
const useBinaryState = require('./useBinaryState');
const { default: useFullscreen } = require('./useFullscreen');
const { default: useInterval } = require('./useInterval');
const { default: useDiscord } = require('./useDiscord');
const useLiveRef = require('./useLiveRef');
const useModelState = require('./useModelState');
const useNotifications = require('./useNotifications');
@ -44,6 +44,8 @@ module.exports = {
useToast,
TooltipProvider,
Tooltip,
DiscordProvider,
useDiscord,
CONSTANTS,
withCoreSuspender,
useCoreSuspender,
@ -56,7 +58,6 @@ module.exports = {
useBinaryState,
useFullscreen,
useInterval,
useDiscord,
useLiveRef,
useModelState,
useNotifications,

View file

@ -1,83 +0,0 @@
// Copyright (C) 2017-2025 Smart code 203358507
import { useCallback, useEffect, useState } from 'react';
import { useServices } from 'stremio/services';
type Service = {
available: boolean;
connected: boolean;
connect: () => void;
disconnect: () => void;
setActivity: (state: string, details: string, image?: string, startTimestamp?: number) => void;
clearActivity: () => void;
on: (name: string, listener: (data: unknown) => void) => void;
off: (name: string, listener: (data: unknown) => void) => void;
};
type Result = {
available: boolean;
connected: boolean;
connect: () => void;
disconnect: () => void;
setActivity: (state: string, details: string, image?: string, startTimestamp?: number) => void;
clearActivity: () => void;
};
const useDiscord = (): Result => {
const { discord } = useServices() as { discord?: Service };
const [connected, setConnected] = useState(discord?.connected ?? false);
useEffect(() => {
if (!discord) return;
const onStatusChanged = (isConnected: boolean) => {
setConnected(isConnected);
};
discord.on('statusChanged', onStatusChanged as (data: unknown) => void);
return () => {
discord.off('statusChanged', onStatusChanged as (data: unknown) => void);
};
}, [discord]);
const connect = useCallback(() => {
if (discord) {
discord.connect();
}
}, [discord]);
const disconnect = useCallback(() => {
if (discord) {
discord.disconnect();
}
}, [discord]);
const setActivity = useCallback((
state: string,
details: string,
image?: string,
startTimestamp?: number
) => {
if (discord) {
discord.setActivity(state, details, image, startTimestamp);
}
}, [discord]);
const clearActivity = useCallback(() => {
if (discord) {
discord.clearActivity();
}
}, [discord]);
return {
available: discord?.available ?? false,
connected,
connect,
disconnect,
setActivity,
clearActivity
};
};
export default useDiscord;

View file

@ -609,20 +609,26 @@ const Player = ({ urlParams, queryParams }) => {
}, [settings.pauseOnMinimize, shell.windowClosed, shell.windowHidden]);
React.useEffect(() => {
if (!discord.connected || !discord.available) return;
if (video.state.stream === null || typeof player?.title !== 'string') {
discord.setActivity(null);
return;
}
const state = video.state.paused ? 'Paused' : 'Watching';
const startTimestamp = !video.state.paused && video.state.time !== null && video.state.duration !== null
? Math.floor((Date.now() / 1000) - video.state.time)
: null;
discord.setActivity(state, player?.title, player?.metaItem?.poster, startTimestamp);
discord.setActivity({
state: video.state.paused ? 'Paused' : 'Watching',
details: player.title,
image: player.metaItem?.poster || null,
startTimestamp: !video.state.paused && typeof video.state.time === 'number' ?
Math.floor((Date.now() / 1000) - video.state.time) :
null,
});
}, [discord.setActivity, player?.title, player.metaItem?.poster, video.state.paused, video.state.stream]);
React.useEffect(() => {
return () => {
discord.clearActivity();
discord.setActivity(null);
};
}, [discord.connected, discord.available, player?.title, player?.metaItem, video.state]);
}, [discord.setActivity]);
useMediaSession(video.state, player, onPlayRequested, onPauseRequested, onNextVideoRequested);

View file

@ -12,6 +12,6 @@
.discord-container {
.option-icon {
color: #5865F2 !important;
color: #5865f2 !important;
}
}

View file

@ -17,7 +17,7 @@ const General = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
const { core } = useServices();
const platform = usePlatform();
const toast = useToast();
const { available: discordAvailable, connected: isDiscordConnected, connect: connectDiscord, disconnect: disconnectDiscord } = useDiscord();
const discord = useDiscord();
const [dataExport, loadDataExport] = useDataExport();
const [traktAuthStarted, setTraktAuthStarted] = useState(false);
@ -63,30 +63,17 @@ const General = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
}, [isTraktAuthenticated, profile.auth]);
const onToggleDiscord = useCallback(() => {
if (isDiscordConnected) {
disconnectDiscord();
core.transport.dispatch({
action: 'Ctx',
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
action: 'UpdateSettings',
args: {
discordRpcEnabled: false
}
...profile.settings,
discordRpcEnabled: !profile.settings.discordRpcEnabled
}
});
} else {
connectDiscord();
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
discordRpcEnabled: true
}
}
});
}
}, [isDiscordConnected, connectDiscord, disconnectDiscord]);
}
});
}, [profile.settings]);
useEffect(() => {
if (dataExport.exportUrl) {
@ -94,12 +81,6 @@ const General = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
}
}, [dataExport.exportUrl]);
useEffect(() => {
if (discordAvailable && profile.settings.discordRpcEnabled && !isDiscordConnected) {
connectDiscord();
}
}, [discordAvailable, profile.settings.discordRpcEnabled]);
useEffect(() => {
if (isTraktAuthenticated && traktAuthStarted) {
core.transport.dispatch({
@ -168,10 +149,10 @@ const General = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
</Button>
</Option>
{
discordAvailable &&
<Option className={styles['discord-container']} icon={'discord'} label={t('SETTINGS_DISCORD')}>
<Button className={'button'} title={isDiscordConnected ? t('DISCONNECT') : t('SETTINGS_DISCORD_CONNECT')} tabIndex={-1} onClick={onToggleDiscord}>
{isDiscordConnected ? t('DISCONNECT') : t('SETTINGS_DISCORD_CONNECT')}
discord.available &&
<Option className={styles['discord-container']} icon={'discord'} label={t('SETTINGS_DISCORD', { defaultValue: 'Discord Rich Presence' })}>
<Button className={'button'} title={profile.settings.discordRpcEnabled ? t('MOBILE_DISCONNECT') : t('SETTINGS_DISCORD_CONNECT', { defaultValue: 'Connect' })} tabIndex={-1} onClick={onToggleDiscord}>
{profile.settings.discordRpcEnabled ? t('MOBILE_DISCONNECT') : t('SETTINGS_DISCORD_CONNECT', { defaultValue: 'Connect' })}
</Button>
</Option>
}