diff --git a/opennow-stable/package-lock.json b/opennow-stable/package-lock.json index 1afd9256..634d35eb 100644 --- a/opennow-stable/package-lock.json +++ b/opennow-stable/package-lock.json @@ -8,6 +8,7 @@ "name": "opennow-stable", "version": "0.2.4", "dependencies": { + "@xhayper/discord-rpc": "^1.3.0", "lucide-react": "^0.563.0", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -344,6 +345,56 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -1658,6 +1709,26 @@ "win32" ] }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -1906,6 +1977,31 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@xhayper/discord-rpc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@xhayper/discord-rpc/-/discord-rpc-1.3.0.tgz", + "integrity": "sha512-0NmUTiODl7u3UEjmO6y0Syp3dmgVLAt2EHrH4QKTQcXRwtF8Wl7Eipdn/GSSZ8HkDwxQFvcDGJMxT9VWB0pH8g==", + "license": "ISC", + "dependencies": { + "@discordjs/rest": "^2.5.1", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18.20.7" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -3408,6 +3504,15 @@ "node": "*" } }, + "node_modules/discord-api-types": { + "version": "0.38.39", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.39.tgz", + "integrity": "sha512-XRdDQvZvID1XvcFftjSmd4dcmMi/RL/jSy5sduBDAvCGFcNFHThdIQXCEBDZFe52lCNEzuIL0QJoKYAmRmxLUA==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, "node_modules/dmg-builder": { "version": "25.1.8", "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-25.1.8.tgz", @@ -5045,6 +5150,12 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6632,6 +6743,12 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -6660,6 +6777,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/opennow-stable/package.json b/opennow-stable/package.json index 65fcb59a..3d7cdd96 100644 --- a/opennow-stable/package.json +++ b/opennow-stable/package.json @@ -23,6 +23,7 @@ "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@xhayper/discord-rpc": "^1.3.0", "lucide-react": "^0.563.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/opennow-stable/src/main/discord/DiscordPresenceService.ts b/opennow-stable/src/main/discord/DiscordPresenceService.ts new file mode 100644 index 00000000..fe5d61c9 --- /dev/null +++ b/opennow-stable/src/main/discord/DiscordPresenceService.ts @@ -0,0 +1,222 @@ +import { Client } from "@xhayper/discord-rpc"; +import type { DiscordPresencePayload } from "@shared/gfn"; + +const RECONNECT_DELAY_MS = 15_000; +const MAX_RECONNECT_DELAY_MS = 120_000; + +export class DiscordPresenceService { + private client: Client | null = null; + private clientId: string; + private enabled: boolean; + private connected = false; + private disposed = false; + private reconnectTimer: NodeJS.Timeout | null = null; + private reconnectAttempt = 0; + private lastPayload: DiscordPresencePayload | null = null; + private readonly appStartedAtMs: number; + + constructor(enabled: boolean, clientId: string) { + this.enabled = enabled; + this.clientId = clientId; + this.appStartedAtMs = Date.now(); + } + + async initialize(): Promise { + if (!this.enabled || !this.clientId) { + return; + } + await this.connect(); + } + + private async connect(): Promise { + if (this.disposed || !this.enabled || !this.clientId) { + return; + } + + this.clearReconnectTimer(); + + try { + const client = new Client({ clientId: this.clientId }); + + client.on("ready", () => { + console.log("[Discord] RPC connected"); + this.connected = true; + this.reconnectAttempt = 0; + if (this.lastPayload) { + void this.setActivity(this.lastPayload); + } + }); + + client.on("disconnected", () => { + console.log("[Discord] RPC disconnected"); + this.connected = false; + this.client = null; + this.scheduleReconnect(); + }); + + await client.login(); + this.client = client; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes("ENOENT") || msg.includes("Could not connect")) { + console.log("[Discord] Discord not running, will retry later"); + } else { + console.warn("[Discord] RPC connect failed:", msg); + } + this.connected = false; + this.client = null; + this.scheduleReconnect(); + } + } + + private scheduleReconnect(): void { + if (this.disposed || !this.enabled || !this.clientId) { + return; + } + + this.clearReconnectTimer(); + const delay = Math.min( + RECONNECT_DELAY_MS * 2 ** this.reconnectAttempt, + MAX_RECONNECT_DELAY_MS, + ); + this.reconnectAttempt += 1; + console.log(`[Discord] Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempt})`); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + void this.connect(); + }, delay); + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + async updatePresence(payload: DiscordPresencePayload): Promise { + this.lastPayload = payload; + + if (!this.enabled || !this.clientId) { + return; + } + + if (!this.connected || !this.client) { + return; + } + + await this.setActivity(payload); + } + + private async setActivity(payload: DiscordPresencePayload): Promise { + if (!this.client || !this.connected) { + return; + } + + const appTs = new Date(this.appStartedAtMs); + + try { + if (payload.type === "idle") { + await this.client.user?.setActivity({ + details: "Browsing library", + state: "Idle", + largeImageKey: "opennow", + largeImageText: "OpenNOW", + startTimestamp: appTs, + }); + } else if (payload.type === "queue") { + const state = payload.queuePosition + ? `Position #${payload.queuePosition}` + : "Waiting"; + await this.client.user?.setActivity({ + details: payload.gameName ? `In queue — ${payload.gameName}` : "In queue", + state, + largeImageKey: "opennow", + largeImageText: "OpenNOW", + startTimestamp: appTs, + }); + } else if (payload.type === "streaming") { + const name = payload.gameName?.trim(); + const details = name ? `Streaming ${name}` : "Streaming"; + + const stateParts: string[] = []; + if (payload.resolution && payload.fps) { + stateParts.push(`${payload.resolution} @ ${payload.fps}fps`); + } else if (payload.resolution) { + stateParts.push(payload.resolution); + } + if (payload.bitrateMbps && payload.bitrateMbps > 0) { + stateParts.push(`${payload.bitrateMbps.toFixed(1)} Mbps`); + } + const state = stateParts.length > 0 ? stateParts.join(" · ") : undefined; + const streamTs = payload.startTimestamp ? new Date(payload.startTimestamp) : appTs; + + await this.client.user?.setActivity({ + details, + ...(state ? { state } : {}), + largeImageKey: "opennow", + largeImageText: "OpenNOW", + startTimestamp: streamTs, + }); + } + } catch (error) { + console.warn("[Discord] Failed to set activity:", error instanceof Error ? error.message : error); + } + } + + async clearPresence(): Promise { + this.lastPayload = null; + + if (!this.client || !this.connected) { + return; + } + + try { + await this.client.user?.clearActivity(); + } catch (error) { + console.warn("[Discord] Failed to clear activity:", error instanceof Error ? error.message : error); + } + } + + async updateConfig(enabled: boolean, clientId: string): Promise { + const wasEnabled = this.enabled; + const oldClientId = this.clientId; + + this.enabled = enabled; + this.clientId = clientId; + + if (!enabled) { + await this.clearPresence(); + await this.disconnect(); + return; + } + + if (enabled && (!wasEnabled || clientId !== oldClientId)) { + await this.disconnect(); + this.disposed = false; + this.reconnectAttempt = 0; + await this.connect(); + return; + } + } + + private async disconnect(): Promise { + this.clearReconnectTimer(); + this.connected = false; + + if (this.client) { + try { + await this.client.destroy(); + } catch { + // ignore + } + this.client = null; + } + } + + async dispose(): Promise { + this.disposed = true; + this.lastPayload = null; + await this.disconnect(); + } +} diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index c15821f8..76c93eea 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -29,6 +29,7 @@ import type { VideoAccelerationPreference, SubscriptionFetchRequest, SessionConflictChoice, + DiscordPresencePayload, } from "@shared/gfn"; import { getSettingsManager, type SettingsManager } from "./settings"; @@ -44,6 +45,7 @@ import { import { fetchSubscription, fetchDynamicRegions } from "./gfn/subscription"; import { GfnSignalingClient } from "./gfn/signaling"; import { isSessionError, SessionError, GfnErrorCode } from "./gfn/errorCodes"; +import { DiscordPresenceService } from "./discord/DiscordPresenceService"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -184,6 +186,7 @@ let signalingClient: GfnSignalingClient | null = null; let signalingClientKey: string | null = null; let authService: AuthService; let settingsManager: SettingsManager; +let discordService: DiscordPresenceService; function emitToRenderer(event: MainToRendererSignalingEvent): void { if (mainWindow && !mainWindow.isDestroyed()) { @@ -489,6 +492,10 @@ function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.SETTINGS_SET, async (_event: Electron.IpcMainInvokeEvent, key: K, value: Settings[K]) => { settingsManager.set(key, value); + if (key === "discordPresenceEnabled" || key === "discordClientId") { + const all = settingsManager.getAll(); + void discordService.updateConfig(all.discordPresenceEnabled, all.discordClientId); + } }); ipcMain.handle(IPC_CHANNELS.SETTINGS_RESET, async (): Promise => { @@ -498,6 +505,14 @@ function registerIpcHandlers(): void { // Logs export IPC handler ipcMain.handle(IPC_CHANNELS.LOGS_EXPORT, async (_event, format: "text" | "json" = "text"): Promise => { return exportLogs(format); + +// Discord Rich Presence IPC handlers + ipcMain.handle(IPC_CHANNELS.DISCORD_UPDATE_PRESENCE, async (_event, payload: DiscordPresencePayload) => { + await discordService.updatePresence(payload); + }); + + ipcMain.handle(IPC_CHANNELS.DISCORD_CLEAR_PRESENCE, async () => { + await discordService.clearPresence(); }); // Save window size when it changes @@ -569,6 +584,13 @@ app.whenReady().then(async () => { return allowedPermissions.has(permission); }); +const allSettings = settingsManager.getAll(); + discordService = new DiscordPresenceService( + allSettings.discordPresenceEnabled, + allSettings.discordClientId, + ); + void discordService.initialize(); + registerIpcHandlers(); await createMainWindow(); @@ -589,6 +611,7 @@ app.on("before-quit", () => { signalingClient?.disconnect(); signalingClient = null; signalingClientKey = null; + void discordService.dispose(); }); // Export for use by other modules diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index 6f3226e9..aabd964b 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -48,6 +48,10 @@ export interface Settings { windowWidth: number; /** Window height */ windowHeight: number; + /** Enable Discord Rich Presence */ + discordPresenceEnabled: boolean; + /** Discord Application Client ID */ + discordClientId: string; } const defaultStopShortcut = "Ctrl+Shift+Q"; @@ -79,6 +83,8 @@ const DEFAULT_SETTINGS: Settings = { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, + discordPresenceEnabled: false, + discordClientId: "", }; export class SettingsManager { diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index 155321c2..1bbb0ffe 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -18,6 +18,7 @@ import type { IceCandidatePayload, Settings, SubscriptionFetchRequest, + DiscordPresencePayload, } from "@shared/gfn"; // Extend the OpenNowApi interface for internal preload use @@ -74,6 +75,9 @@ const api: PreloadApi = { ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_SET, key, value), resetSettings: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_RESET), exportLogs: (format?: "text" | "json") => ipcRenderer.invoke(IPC_CHANNELS.LOGS_EXPORT, format), +updateDiscordPresence: (state: DiscordPresencePayload) => + ipcRenderer.invoke(IPC_CHANNELS.DISCORD_UPDATE_PRESENCE, state), + clearDiscordPresence: () => ipcRenderer.invoke(IPC_CHANNELS.DISCORD_CLEAR_PRESENCE), }; contextBridge.exposeInMainWorld("openNow", api); diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index f62a8bde..f61bbe5a 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -13,6 +13,7 @@ import type { SubscriptionInfo, StreamRegion, VideoCodec, + DiscordPresencePayload, } from "@shared/gfn"; import { @@ -286,6 +287,8 @@ export function App(): JSX.Element { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, + discordPresenceEnabled: false, + discordClientId: "", }); const [settingsLoaded, setSettingsLoaded] = useState(false); const [regions, setRegions] = useState([]); @@ -345,6 +348,7 @@ export function App(): JSX.Element { const sessionRef = useRef(null); const hasInitializedRef = useRef(false); const regionsRequestRef = useRef(0); + const lastStreamGameTitleRef = useRef(null); const launchInFlightRef = useRef(false); const exitPromptResolverRef = useRef<((confirmed: boolean) => void) | null>(null); @@ -658,6 +662,56 @@ export function App(): JSX.Element { return () => window.clearInterval(timer); }, [sessionStartedAtMs, streamStatus]); + // Discord Rich Presence updates + useEffect(() => { + if (!settings.discordPresenceEnabled || !settings.discordClientId) { + return; + } + + let payload: DiscordPresencePayload; + + if (streamStatus === "idle") { + payload = { type: "idle" }; + } else if (streamStatus === "queue" || streamStatus === "setup") { + const queueTitle = streamingGame?.title?.trim() || lastStreamGameTitleRef.current || undefined; + payload = { + type: "queue", + gameName: queueTitle, + queuePosition, + }; + } else { + const hasDiag = diagnostics.resolution !== "" || diagnostics.bitrateKbps > 0; + const gameTitle = streamingGame?.title?.trim() || lastStreamGameTitleRef.current || undefined; + payload = { + type: "streaming", + gameName: gameTitle, + startTimestamp: sessionStartedAtMs ?? undefined, + ...(hasDiag && diagnostics.resolution ? { resolution: diagnostics.resolution } : {}), + ...(hasDiag && diagnostics.decodeFps > 0 ? { fps: diagnostics.decodeFps } : {}), + ...(hasDiag && diagnostics.bitrateKbps > 0 ? { bitrateMbps: Math.round(diagnostics.bitrateKbps / 100) / 10 } : {}), + }; + } + + window.openNow.updateDiscordPresence(payload).catch(() => {}); + }, [ + streamStatus, + streamingGame?.title, + sessionStartedAtMs, + queuePosition, + diagnostics.resolution, + diagnostics.decodeFps, + diagnostics.bitrateKbps, + settings.discordPresenceEnabled, + settings.discordClientId, + ]); + + // Clear Discord presence on logout + useEffect(() => { + if (!authSession) { + window.openNow.clearDiscordPresence().catch(() => {}); + } + }, [authSession]); + useEffect(() => { if (!streamWarning) return; const warning = streamWarning; @@ -736,6 +790,7 @@ export function App(): JSX.Element { setStreamStatus("idle"); setSession(null); setStreamingGame(null); + lastStreamGameTitleRef.current = null; setLaunchError(null); setSessionStartedAtMs(null); setSessionElapsedSeconds(0); @@ -917,6 +972,7 @@ export function App(): JSX.Element { setStreamWarning(null); setLaunchError(null); setStreamingGame(game); + lastStreamGameTitleRef.current = game.title?.trim() || null; updateLoadingStep("queue"); setQueuePosition(undefined); @@ -1115,6 +1171,7 @@ export function App(): JSX.Element { setSessionStartedAtMs(Date.now()); setSessionElapsedSeconds(0); setStreamWarning(null); + lastStreamGameTitleRef.current = activeSessionGameTitle?.trim() || null; updateLoadingStep("setup"); try { @@ -1171,6 +1228,7 @@ export function App(): JSX.Element { setSession(null); setStreamStatus("idle"); setStreamingGame(null); + lastStreamGameTitleRef.current = null; setNavbarActiveSession(null); setLaunchError(null); setSessionStartedAtMs(null); @@ -1191,6 +1249,7 @@ export function App(): JSX.Element { setSession(null); setLaunchError(null); setStreamingGame(null); + lastStreamGameTitleRef.current = null; setQueuePosition(undefined); setSessionStartedAtMs(null); setSessionElapsedSeconds(0); diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index a19c115e..686446b6 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -1,4 +1,4 @@ -import { Globe, Save, Check, Search, X, Loader, Zap, Mic, FileDown } from "lucide-react"; +import { Globe, Save, Check, Search, X, Loader, Zap, Mic, FileDown, MessageSquare } from "lucide-react"; import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import type { JSX } from "react"; @@ -1420,7 +1420,43 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag - {/* Footer */} + {/* ── Discord ────────────────────────────────────── */} +
+
+ +

Discord

+
+
+
+ + +
+
+ + handleChange("discordClientId", e.target.value)} + disabled={!settings.discordPresenceEnabled} + spellCheck={false} + autoComplete="off" + /> + + Create an application at discord.com/developers and paste its Client ID here. + +
+
+
+