This commit is contained in:
Timothy Z. 2026-05-22 20:59:34 +03:00 committed by GitHub
commit 3b4f2b9091
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 317 additions and 20 deletions

View file

@ -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 = () => {
<GamepadProvider enabled={gamepadSupportEnabled} onGuide={toggleGamepadModal}>
<ShortcutsProvider onShortcut={onShortcut}>
<FullscreenProvider>
{
shortcutModalOpen && <ShortcutsModal onClose={closeShortcutsModal}/>
}
{
gamepadModalOpen && <GamepadModal onClose={closeGamepadModal}/>
}
<ServicesToaster />
<DeepLinkHandler />
<SearchParamsHandler />
<UpdaterBanner className={styles['updater-banner-container']} />
<RouterWithProtectedRoutes
className={styles['router']}
viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch}
/>
<DiscordProvider>
{
shortcutModalOpen && <ShortcutsModal onClose={closeShortcutsModal}/>
}
{
gamepadModalOpen && <GamepadModal onClose={closeGamepadModal}/>
}
<ServicesToaster />
<DeepLinkHandler />
<SearchParamsHandler />
<UpdaterBanner className={styles['updater-banner-container']} />
<RouterWithProtectedRoutes
className={styles['router']}
viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch}
/>
</DiscordProvider>
</FullscreenProvider>
</ShortcutsProvider>
</GamepadProvider>

View file

@ -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<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, setAvailable] = useState(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);
};
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 (
<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

@ -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,

View file

@ -47,4 +47,3 @@ const Error = () => {
};
export default Error;

View file

@ -18,6 +18,7 @@ type Settings = {
audioPassthrough: boolean,
autoFrameRateMatching: boolean,
bingeWatching: boolean,
discordRpcEnabled: boolean,
hardwareDecoding: boolean,
videoMode: string | null,
escExitFullscreen: boolean,

View file

@ -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(() => {

View file

@ -9,3 +9,9 @@
color: var(--color-trakt) !important;
}
}
.discord-container {
.option-icon {
color: #5865f2 !important;
}
}

View file

@ -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<HTMLDivElement, Props>(({ 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<HTMLDivElement, Props>(({ 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<HTMLDivElement, Props>(({ profile }: Props, ref) => {
{isTraktAuthenticated ? t('LOG_OUT') : t('SETTINGS_TRAKT_AUTHENTICATE')}
</Button>
</Option>
{
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>
}
</Section>
</>;
});

View file

@ -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;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2025 Smart code 203358507
import Discord from './Discord';
export default Discord;

View file

@ -1,3 +1,4 @@
type ServicesContext = {
chromecast: any,
discord: any,
};

View file

@ -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,