From 95c59da3105058564fbbd60ac7105421c325a111 Mon Sep 17 00:00:00 2001 From: Jared Date: Fri, 27 Feb 2026 16:08:14 -0600 Subject: [PATCH 01/17] feat(ui): implement sidebar for settings with mouse sensitivity and bitrate controls --- opennow-stable/src/renderer/src/App.tsx | 13 ++ .../renderer/src/components/SettingsPage.tsx | 9 + .../src/renderer/src/components/SideBar.tsx | 42 ++++ .../renderer/src/components/StreamView.tsx | 191 +++++++++++++++++- opennow-stable/src/renderer/src/styles.css | 156 ++++++++++++++ 5 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 opennow-stable/src/renderer/src/components/SideBar.tsx diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index e471bd4..d4ea095 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -621,6 +621,12 @@ export function App(): JSX.Element { .catch(() => {}); }, []); + const handleRequestPointerLock = useCallback(() => { + if (videoRef.current) { + void requestEscLockedPointerCapture(videoRef.current); + } + }, [requestEscLockedPointerCapture]); + const resolveExitPrompt = useCallback((confirmed: boolean) => { const resolver = exitPromptResolverRef.current; exitPromptResolverRef.current = null; @@ -836,6 +842,10 @@ export function App(): JSX.Element { } }, [settingsLoaded]); + const handleMouseSensitivityChange = useCallback((value: number) => { + void updateSetting("mouseSensitivity", value); + }, [updateSetting]); + // Login handler const handleLogin = useCallback(async () => { setIsLoggingIn(true); @@ -1527,6 +1537,9 @@ export function App(): JSX.Element { onToggleMicrophone={() => { clientRef.current?.toggleMicrophone(); }} + mouseSensitivity={settings.mouseSensitivity} + onMouseSensitivityChange={handleMouseSensitivityChange} + onRequestPointerLock={handleRequestPointerLock} /> )} {streamStatus !== "streaming" && ( diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index 0fb8606..33e72d1 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -1540,6 +1540,15 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag spellCheck={false} /> + {(toggleStatsError || togglePointerLockError || stopStreamError || toggleAntiAfkError || toggleMicrophoneError) && ( diff --git a/opennow-stable/src/renderer/src/components/SideBar.tsx b/opennow-stable/src/renderer/src/components/SideBar.tsx new file mode 100644 index 0000000..f3c06e3 --- /dev/null +++ b/opennow-stable/src/renderer/src/components/SideBar.tsx @@ -0,0 +1,42 @@ +import type { JSX, ReactNode } from "react"; + +interface SideBarProps { + title?: string; + children?: ReactNode; + className?: string; + onClose?: () => void; +} + +export default function SideBar({ + title, + children, + className = "", + onClose, +}: SideBarProps): JSX.Element { + const classNames = ["sidebar", className].filter(Boolean).join(" "); + + return ( + + ); +} \ No newline at end of file diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index 9f2b810..fa70c05 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -1,8 +1,10 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import type { JSX } from "react"; import { Maximize, Minimize, Gamepad2, Loader2, LogOut, Clock3, AlertTriangle, Mic, MicOff } from "lucide-react"; +import SideBar from "./SideBar"; import type { StreamDiagnostics } from "../gfn/webrtcClient"; import { getStoreDisplayName, getStoreIconComponent } from "./GameCard"; +import type { MicrophoneMode } from "@shared/gfn"; interface StreamViewProps { videoRef: React.Ref; @@ -44,6 +46,13 @@ interface StreamViewProps { onCancelExit: () => void; onEndSession: () => void; onToggleMicrophone?: () => void; + mouseSensitivity: number; + onMouseSensitivityChange: (value: number) => void; + onRequestPointerLock?: () => void; + maxBitrateMbps: number; + onMaxBitrateChange: (value: number) => void; + microphoneMode: MicrophoneMode; + onMicrophoneModeChange: (value: MicrophoneMode) => void; } function getRttColor(rttMs: number): string { @@ -119,22 +128,49 @@ export function StreamView({ onCancelExit, onEndSession, onToggleMicrophone, + mouseSensitivity, + onMouseSensitivityChange, + onRequestPointerLock, + maxBitrateMbps, + onMaxBitrateChange, + microphoneMode, + onMicrophoneModeChange, hideStreamButtons = false, }: StreamViewProps): JSX.Element { const [isFullscreen, setIsFullscreen] = useState(false); const [showHints, setShowHints] = useState(true); const [showSessionClock, setShowSessionClock] = useState(false); + const [showSideBar, setShowSideBar] = useState(false); + const [isPointerLocked, setIsPointerLocked] = useState(false); // Microphone state const micState = stats.micState ?? "uninitialized"; const micEnabled = stats.micEnabled ?? false; const hasMicrophone = micState === "started" || micState === "stopped"; const showMicIndicator = hasMicrophone && !isConnecting && !hideStreamButtons; + const microphoneModes = useMemo( + () => [ + { value: "disabled" as MicrophoneMode, label: "Disabled", description: "No microphone input" }, + { value: "push-to-talk" as MicrophoneMode, label: "Push-to-Talk", description: "Hold a key to talk" }, + { value: "voice-activity" as MicrophoneMode, label: "Voice Activity", description: "Always listen" }, + ], + [] + ); const handleFullscreenToggle = useCallback(() => { onToggleFullscreen(); }, [onToggleFullscreen]); + const handlePointerLockToggle = useCallback(() => { + if (isPointerLocked) { + document.exitPointerLock(); + return; + } + if (onRequestPointerLock) { + onRequestPointerLock(); + } + }, [isPointerLocked, onRequestPointerLock]); + useEffect(() => { const timer = setTimeout(() => setShowHints(false), 5000); return () => clearTimeout(timer); @@ -226,6 +262,20 @@ export function StreamView({ } }, [videoRef]); + useEffect(() => { + const handlePointerLockChange = () => { + setIsPointerLocked(document.pointerLockElement === localVideoRef.current); + }; + document.addEventListener("pointerlockchange", handlePointerLockChange); + return () => document.removeEventListener("pointerlockchange", handlePointerLockChange); + }, []); + + useEffect(() => { + if (showSideBar && document.pointerLockElement) { + document.exitPointerLock(); + } + }, [showSideBar]); + // Focus video element when stream is ready (not connecting anymore) useEffect(() => { if (!isConnecting && localVideoRef.current && hasResolution) { @@ -240,6 +290,36 @@ export function StreamView({ } }, [isConnecting, hasResolution]); + const handleToggleSideBar = useCallback(() => { + setShowSideBar((s) => { + if (!s && document.pointerLockElement) { + document.exitPointerLock(); + } + return !s; + }); + }, []); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + // Toggle: CMD+G on macOS, Ctrl+Shift+G elsewhere + const key = event.key.toLowerCase(); + const isMac = navigator.platform?.toLowerCase().includes("mac") || navigator.userAgent.includes("Macintosh"); + if (isMac) { + if (event.metaKey && !event.ctrlKey && !event.shiftKey && key === "g") { + event.preventDefault(); + handleToggleSideBar(); + } + } else { + if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "g") { + event.preventDefault(); + handleToggleSideBar(); + } + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [handleToggleSideBar]); + return (
{/* Video element */} @@ -259,6 +339,115 @@ export function StreamView({ />
-
-
- Max Bitrate - Cap the encoder bandwidth -
-
-
- Bandwidth Ceiling - {maxBitrateMbps} Mbps -
- { - const next = Number(event.target.value); - if (Number.isFinite(next)) { - onMaxBitrateChange(Math.max(5, Math.min(150, next))); - } - }} - /> -
-
Audio From bcd792f79e632dc3138239fd5e5f3461a74c4a56 Mon Sep 17 00:00:00 2001 From: Jared Date: Wed, 4 Mar 2026 19:33:51 -0600 Subject: [PATCH 06/17] feat(ui): add remaining playtime display in StreamView sidebar and format subscription info --- opennow-stable/src/renderer/src/App.tsx | 21 +++++++++++++++++++ .../renderer/src/components/StreamView.tsx | 7 +++++++ opennow-stable/src/renderer/src/styles.css | 18 ++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index d764990..1386621 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -184,6 +184,25 @@ function warningMessage(code: StreamTimeWarning["code"]): string { return "Maximum session time approaching"; } +function formatRemainingPlaytimeFromSubscription(subscription: SubscriptionInfo | null): string { + if (!subscription) { + return "--"; + } + if (subscription.isUnlimited) { + return "Unlimited"; + } + + const safeHours = Math.max(0, Number.isFinite(subscription.remainingHours) ? subscription.remainingHours : 0); + const totalMinutes = Math.round(safeHours * 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + if (hours > 0) { + return `${hours}h ${minutes.toString().padStart(2, "0")}m`; + } + return `${minutes}m`; +} + function toLoadingStatus(status: StreamStatus): StreamLoadingStatus { switch (status) { case "queue": @@ -1509,6 +1528,7 @@ export function App(): JSX.Element { } const showLaunchOverlay = streamStatus !== "idle" || launchError !== null; + const remainingPlaytimeText = formatRemainingPlaytimeFromSubscription(subscriptionInfo); // Show stream lifecycle (waiting/connecting/streaming/failure) if (showLaunchOverlay) { @@ -1559,6 +1579,7 @@ export function App(): JSX.Element { onMouseSensitivityChange={handleMouseSensitivityChange} microphoneMode={settings.microphoneMode} onMicrophoneModeChange={handleMicrophoneModeChange} + remainingPlaytimeText={remainingPlaytimeText} micTrack={clientRef.current?.getMicTrack() ?? null} onRequestPointerLock={handleRequestPointerLock} onReleasePointerLock={() => { diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index 2440708..10ff9fd 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -52,6 +52,7 @@ interface StreamViewProps { onReleasePointerLock?: () => void; microphoneMode: MicrophoneMode; onMicrophoneModeChange: (value: MicrophoneMode) => void; + remainingPlaytimeText: string; micTrack?: MediaStreamTrack | null; } @@ -224,6 +225,7 @@ export function StreamView({ onReleasePointerLock, microphoneMode, onMicrophoneModeChange, + remainingPlaytimeText, micTrack, hideStreamButtons = false, }: StreamViewProps): JSX.Element { @@ -445,6 +447,11 @@ export function StreamView({ onClick={() => setShowSideBar(false)} /> setShowSideBar(false)}> +
+ Remaining Playtime + {remainingPlaytimeText} +
+ + )} + {/* Gradient background when no video */} {!hasResolution && (
diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 01cf1eb..1de2e91 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -3121,6 +3121,9 @@ button.game-card-store-chip.active:hover { font-size: 0.72rem; color: var(--ink-muted); } +.sidebar-hint--error { + color: #fda4af; +} .sidebar-button { border-radius: 4px; border: 1px solid var(--panel-border); @@ -3139,6 +3142,11 @@ button.game-card-store-chip.active:hover { .sidebar-button:active { transform: translateY(0); } +.sidebar-screenshot-button { + display: inline-flex; + align-items: center; + gap: 6px; +} .sidebar-chip-row { display: flex; flex-wrap: wrap; @@ -3164,6 +3172,60 @@ button.game-card-store-chip.active:hover { background: rgba(119, 187, 255, 0.12); color: var(--accent); } +.sidebar-gallery-row { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) 24px; + gap: 8px; + align-items: center; +} +.sidebar-gallery-arrow { + width: 24px; + height: 24px; + border-radius: 6px; + border: 1px solid var(--panel-border); + background: var(--card); + color: var(--ink-soft); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); +} +.sidebar-gallery-arrow:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--card-hover); +} +.sidebar-gallery-strip { + display: flex; + align-items: center; + gap: 8px; + overflow-x: auto; + padding: 2px 2px 6px; + scroll-behavior: smooth; +} +.sidebar-gallery-item { + width: 88px; + height: 50px; + flex: 0 0 auto; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--panel-border); + background: #08090b; + padding: 0; + cursor: pointer; + transition: transform var(--t-fast), border-color var(--t-fast); +} +.sidebar-gallery-item:hover { + transform: translateY(-1px); + border-color: var(--accent); +} +.sidebar-gallery-item img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} .sv-sidebar { width: min(360px, 92vw); } @@ -3181,6 +3243,99 @@ button.game-card-store-chip.active:hover { border-radius: 3px; } +.sv-shot-modal { + position: fixed; + inset: 0; + z-index: 1400; + display: flex; + align-items: center; + justify-content: center; + padding: 18px; +} +.sv-shot-modal-backdrop { + position: absolute; + inset: 0; + border: none; + background: rgba(5, 6, 8, 0.72); + backdrop-filter: blur(8px); +} +.sv-shot-modal-card { + position: relative; + z-index: 1; + width: min(980px, calc(100vw - 32px)); + max-height: calc(100vh - 36px); + padding: 14px; + border-radius: 14px; + border: 1px solid var(--panel-border); + background: rgba(10, 10, 12, 0.96); + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + gap: 10px; +} +.sv-shot-modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.sv-shot-modal-head h4 { + margin: 0; + font-size: 0.86rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--ink-soft); +} +.sv-shot-modal-close { + width: 26px; + height: 26px; + border: 1px solid var(--panel-border); + border-radius: 6px; + background: var(--card); + color: var(--ink-muted); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.sv-shot-modal-close:hover { + color: var(--ink); + border-color: var(--panel-border-solid); +} +.sv-shot-modal-image { + width: 100%; + max-height: calc(100vh - 180px); + object-fit: contain; + border-radius: 10px; + border: 1px solid var(--panel-border); + background: #050607; +} +.sv-shot-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} +.sv-shot-modal-btn { + border: 1px solid var(--panel-border); + background: var(--card); + color: var(--ink); + border-radius: 8px; + padding: 7px 10px; + font-size: 0.8rem; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; +} +.sv-shot-modal-btn:hover { + border-color: var(--accent); +} +.sv-shot-modal-btn--danger:hover { + border-color: rgba(248, 113, 113, 0.55); + color: #ffd4d4; +} + /* ====================================================== REDUCED MOTION diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index 04946c1..f99769b 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -340,4 +340,46 @@ export interface OpenNowApi { exportLogs(format?: "text" | "json"): Promise; /** Ping all regions and return latency results */ pingRegions(regions: StreamRegion[]): Promise; + + /** Persist a PNG screenshot from a renderer-generated data URL */ + saveScreenshot(input: ScreenshotSaveRequest): Promise; + + /** List recent screenshots from the persistent screenshot directory */ + listScreenshots(): Promise; + + /** Delete a screenshot from the persistent screenshot directory */ + deleteScreenshot(input: ScreenshotDeleteRequest): Promise; + + /** Export a screenshot to a user-selected path */ + saveScreenshotAs(input: ScreenshotSaveAsRequest): Promise; + + /** Listen for screenshot hotkey events from the main process (F11) */ + onTriggerScreenshot(listener: () => void): () => void; +} + +export interface ScreenshotSaveRequest { + dataUrl: string; + gameTitle?: string; +} + +export interface ScreenshotDeleteRequest { + id: string; +} + +export interface ScreenshotSaveAsRequest { + id: string; +} + +export interface ScreenshotSaveAsResult { + saved: boolean; + filePath?: string; +} + +export interface ScreenshotEntry { + id: string; + fileName: string; + filePath: string; + createdAtMs: number; + sizeBytes: number; + dataUrl: string; } diff --git a/opennow-stable/src/shared/ipc.ts b/opennow-stable/src/shared/ipc.ts index dd79bc2..7dadfb8 100644 --- a/opennow-stable/src/shared/ipc.ts +++ b/opennow-stable/src/shared/ipc.ts @@ -28,6 +28,10 @@ export const IPC_CHANNELS = { SETTINGS_RESET: "settings:reset", LOGS_EXPORT: "logs:export", LOGS_GET_RENDERER: "logs:get-renderer", + SCREENSHOT_SAVE: "screenshot:save", + SCREENSHOT_LIST: "screenshot:list", + SCREENSHOT_DELETE: "screenshot:delete", + SCREENSHOT_SAVE_AS: "screenshot:save-as", } as const; export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]; From 633f87a51f58da206addfc0221934099ffcda1ac Mon Sep 17 00:00:00 2001 From: Jared Date: Wed, 4 Mar 2026 20:29:14 -0600 Subject: [PATCH 08/17] feat(ui): add configurable screenshot shortcut and update related components --- opennow-stable/src/main/index.ts | 13 +-- opennow-stable/src/main/settings.ts | 3 + opennow-stable/src/renderer/src/App.tsx | 10 ++- .../renderer/src/components/SettingsPage.tsx | 34 +++++++- .../renderer/src/components/StreamView.tsx | 79 +++++++++++++++---- opennow-stable/src/renderer/src/styles.css | 3 + opennow-stable/src/shared/gfn.ts | 1 + 7 files changed, 109 insertions(+), 34 deletions(-) diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index faed5d5..7cff68d 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -6,7 +6,7 @@ import { copyFile, mkdir, readdir, readFile, stat, unlink, writeFile } from "nod import * as net from "node:net"; // Keyboard shortcuts reference (matching Rust implementation): -// F11 - Take screenshot (handled in main process) +// Screenshot keybind - configurable, handled in renderer // F3 - Toggle stats overlay (handled in renderer) // Ctrl+Shift+Q - Stop streaming (handled in renderer) // F8 - Toggle mouse/pointer lock (handled in main process via IPC) @@ -333,17 +333,6 @@ async function createMainWindow(): Promise { }, }); - // Handle F11 screenshot hotkey in main process to avoid browser-level interception. - mainWindow.webContents.on("before-input-event", (event, input) => { - if (input.key === "F11" && input.type === "keyDown") { - event.preventDefault(); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("app:trigger-screenshot"); - } - } - - }); - if (process.platform === "win32") { // Keep native window fullscreen in sync with HTML fullscreen so Windows treats // stream playback like a real fullscreen window instead of only DOM fullscreen. diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index e90d4a1..d5edc37 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -30,6 +30,8 @@ export interface Settings { shortcutToggleAntiAfk: string; /** Toggle microphone shortcut */ shortcutToggleMicrophone: string; + /** Take screenshot shortcut */ + shortcutScreenshot: string; /** How often to re-show the session timer while streaming (0 = off) */ sessionClockShowEveryMinutes: number; /** How long the session timer stays visible when it appears */ @@ -66,6 +68,7 @@ const DEFAULT_SETTINGS: Settings = { shortcutStopStream: defaultStopShortcut, shortcutToggleAntiAfk: defaultAntiAfkShortcut, shortcutToggleMicrophone: defaultMicShortcut, + shortcutScreenshot: "F11", microphoneMode: "disabled", microphoneDeviceId: "", hideStreamButtons: false, diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 1386621..035a664 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -67,6 +67,7 @@ const DEFAULT_SHORTCUTS = { shortcutStopStream: "Ctrl+Shift+Q", shortcutToggleAntiAfk: "Ctrl+Shift+K", shortcutToggleMicrophone: "Ctrl+Shift+M", + shortcutScreenshot: "F11", } as const; function sleep(ms: number): Promise { @@ -327,6 +328,7 @@ export function App(): JSX.Element { shortcutStopStream: DEFAULT_SHORTCUTS.shortcutStopStream, shortcutToggleAntiAfk: DEFAULT_SHORTCUTS.shortcutToggleAntiAfk, shortcutToggleMicrophone: DEFAULT_SHORTCUTS.shortcutToggleMicrophone, + shortcutScreenshot: DEFAULT_SHORTCUTS.shortcutScreenshot, microphoneMode: "disabled", microphoneDeviceId: "", hideStreamButtons: false, @@ -609,13 +611,15 @@ export function App(): JSX.Element { const stopStream = parseWithFallback(settings.shortcutStopStream, DEFAULT_SHORTCUTS.shortcutStopStream); const toggleAntiAfk = parseWithFallback(settings.shortcutToggleAntiAfk, DEFAULT_SHORTCUTS.shortcutToggleAntiAfk); const toggleMicrophone = parseWithFallback(settings.shortcutToggleMicrophone, DEFAULT_SHORTCUTS.shortcutToggleMicrophone); - return { toggleStats, togglePointerLock, stopStream, toggleAntiAfk, toggleMicrophone }; + const screenshot = parseWithFallback(settings.shortcutScreenshot, DEFAULT_SHORTCUTS.shortcutScreenshot); + return { toggleStats, togglePointerLock, stopStream, toggleAntiAfk, toggleMicrophone, screenshot }; }, [ settings.shortcutToggleStats, settings.shortcutTogglePointerLock, settings.shortcutStopStream, settings.shortcutToggleAntiAfk, settings.shortcutToggleMicrophone, + settings.shortcutScreenshot, ]); const requestEscLockedPointerCapture = useCallback(async (target: HTMLVideoElement) => { @@ -1546,6 +1550,7 @@ export function App(): JSX.Element { togglePointerLock: formatShortcutForDisplay(settings.shortcutTogglePointerLock, isMac), stopStream: formatShortcutForDisplay(settings.shortcutStopStream, isMac), toggleMicrophone: formatShortcutForDisplay(settings.shortcutToggleMicrophone, isMac), + screenshot: shortcuts.screenshot.canonical, }} hideStreamButtons={settings.hideStreamButtons} serverRegion={session?.serverIp} @@ -1579,6 +1584,9 @@ export function App(): JSX.Element { onMouseSensitivityChange={handleMouseSensitivityChange} microphoneMode={settings.microphoneMode} onMicrophoneModeChange={handleMicrophoneModeChange} + onScreenshotShortcutChange={(value) => { + void updateSetting("shortcutScreenshot", value); + }} remainingPlaytimeText={remainingPlaytimeText} micTrack={clientRef.current?.getMicTrack() ?? null} onRequestPointerLock={handleRequestPointerLock} diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index 33e72d1..5ea607b 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -69,6 +69,7 @@ const shortcutDefaults = { shortcutStopStream: "Ctrl+Shift+Q", shortcutToggleAntiAfk: "Ctrl+Shift+K", shortcutToggleMicrophone: "Ctrl+Shift+M", + shortcutScreenshot: "F11", } as const; const microphoneModeOptions: Array<{ value: MicrophoneMode; label: string }> = [ @@ -571,11 +572,13 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag const [stopStreamInput, setStopStreamInput] = useState(settings.shortcutStopStream); const [toggleAntiAfkInput, setToggleAntiAfkInput] = useState(settings.shortcutToggleAntiAfk); const [toggleMicrophoneInput, setToggleMicrophoneInput] = useState(settings.shortcutToggleMicrophone); + const [screenshotInput, setScreenshotInput] = useState(settings.shortcutScreenshot); const [toggleStatsError, setToggleStatsError] = useState(false); const [togglePointerLockError, setTogglePointerLockError] = useState(false); const [stopStreamError, setStopStreamError] = useState(false); const [toggleAntiAfkError, setToggleAntiAfkError] = useState(false); const [toggleMicrophoneError, setToggleMicrophoneError] = useState(false); + const [screenshotError, setScreenshotError] = useState(false); // Dynamic entitled resolutions from MES API const [entitledResolutions, setEntitledResolutions] = useState([]); @@ -601,6 +604,10 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag setToggleMicrophoneInput(settings.shortcutToggleMicrophone); }, [settings.shortcutToggleMicrophone]); + useEffect(() => { + setScreenshotInput(settings.shortcutScreenshot); + }, [settings.shortcutScreenshot]); + // Fetch subscription data (cached per account; reload only when account changes) useEffect(() => { let cancelled = false; @@ -814,13 +821,15 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag && settings.shortcutTogglePointerLock === shortcutDefaults.shortcutTogglePointerLock && settings.shortcutStopStream === shortcutDefaults.shortcutStopStream && settings.shortcutToggleAntiAfk === shortcutDefaults.shortcutToggleAntiAfk - && settings.shortcutToggleMicrophone === shortcutDefaults.shortcutToggleMicrophone, + && settings.shortcutToggleMicrophone === shortcutDefaults.shortcutToggleMicrophone + && settings.shortcutScreenshot === shortcutDefaults.shortcutScreenshot, [ settings.shortcutToggleStats, settings.shortcutTogglePointerLock, settings.shortcutStopStream, settings.shortcutToggleAntiAfk, settings.shortcutToggleMicrophone, + settings.shortcutScreenshot, ] ); @@ -830,11 +839,13 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag setStopStreamInput(shortcutDefaults.shortcutStopStream); setToggleAntiAfkInput(shortcutDefaults.shortcutToggleAntiAfk); setToggleMicrophoneInput(shortcutDefaults.shortcutToggleMicrophone); + setScreenshotInput(shortcutDefaults.shortcutScreenshot); setToggleStatsError(false); setTogglePointerLockError(false); setStopStreamError(false); setToggleAntiAfkError(false); setToggleMicrophoneError(false); + setScreenshotError(false); const shortcutKeys = [ "shortcutToggleStats", @@ -842,6 +853,7 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag "shortcutStopStream", "shortcutToggleAntiAfk", "shortcutToggleMicrophone", + "shortcutScreenshot", ] as const; for (const key of shortcutKeys) { @@ -1540,6 +1552,20 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag spellCheck={false} /> + +
- {(toggleStatsError || togglePointerLockError || stopStreamError || toggleAntiAfkError || toggleMicrophoneError) && ( + {(toggleStatsError || togglePointerLockError || stopStreamError || toggleAntiAfkError || toggleMicrophoneError || screenshotError) && ( Invalid shortcut. Use {shortcutExamples} )} - {!toggleStatsError && !togglePointerLockError && !stopStreamError && !toggleAntiAfkError && !toggleMicrophoneError && ( + {!toggleStatsError && !togglePointerLockError && !stopStreamError && !toggleAntiAfkError && !toggleMicrophoneError && !screenshotError && ( - {shortcutExamples}. Stop: {formatShortcutForDisplay(settings.shortcutStopStream, isMac)}. Mic: {formatShortcutForDisplay(settings.shortcutToggleMicrophone, isMac)}. + {shortcutExamples}. Stop: {formatShortcutForDisplay(settings.shortcutStopStream, isMac)}. Mic: {formatShortcutForDisplay(settings.shortcutToggleMicrophone, isMac)}. ScreensShot: {formatShortcutForDisplay(settings.shortcutScreenshot, isMac)}. )}
diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index 5c78ab1..5aae7c4 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -5,6 +5,7 @@ import SideBar from "./SideBar"; import type { StreamDiagnostics } from "../gfn/webrtcClient"; import { getStoreDisplayName, getStoreIconComponent } from "./GameCard"; import type { MicrophoneMode, ScreenshotEntry } from "@shared/gfn"; +import { isShortcutMatch, normalizeShortcut } from "../shortcuts"; interface StreamViewProps { videoRef: React.Ref; @@ -16,6 +17,7 @@ interface StreamViewProps { togglePointerLock: string; stopStream: string; toggleMicrophone?: string; + screenshot: string; }; hideStreamButtons?: boolean; serverRegion?: string; @@ -52,6 +54,7 @@ interface StreamViewProps { onReleasePointerLock?: () => void; microphoneMode: MicrophoneMode; onMicrophoneModeChange: (value: MicrophoneMode) => void; + onScreenshotShortcutChange: (value: string) => void; remainingPlaytimeText: string; micTrack?: MediaStreamTrack | null; } @@ -225,6 +228,7 @@ export function StreamView({ onReleasePointerLock, microphoneMode, onMicrophoneModeChange, + onScreenshotShortcutChange, remainingPlaytimeText, micTrack, hideStreamButtons = false, @@ -238,6 +242,8 @@ export function StreamView({ const [isSavingScreenshot, setIsSavingScreenshot] = useState(false); const [galleryError, setGalleryError] = useState(null); const [selectedScreenshotId, setSelectedScreenshotId] = useState(null); + const [screenshotShortcutInput, setScreenshotShortcutInput] = useState(shortcuts.screenshot); + const [screenshotShortcutError, setScreenshotShortcutError] = useState(false); const screenshotApiAvailable = typeof (window.openNow as any)?.saveScreenshot === "function" && typeof (window.openNow as any)?.listScreenshots === "function" && @@ -362,6 +368,10 @@ export function StreamView({ return screenshots.find((item) => item.id === selectedScreenshotId) ?? null; }, [screenshots, selectedScreenshotId]); + useEffect(() => { + setScreenshotShortcutInput(shortcuts.screenshot); + }, [shortcuts.screenshot]); + const refreshScreenshots = useCallback(async () => { if (!screenshotApiAvailable) { setGalleryError("Screenshot API unavailable. Restart OpenNOW to enable gallery."); @@ -486,17 +496,6 @@ export function StreamView({ } }, [screenshots, selectedScreenshotId]); - useEffect(() => { - if (typeof (window.openNow as any)?.onTriggerScreenshot !== "function") { - return; - } - - const unsubscribe = window.openNow.onTriggerScreenshot(() => { - void captureScreenshot(); - }); - return () => unsubscribe(); - }, [captureScreenshot]); - // Focus video element when stream is ready (not connecting anymore) useEffect(() => { if (!isConnecting && localVideoRef.current && hasResolution) { @@ -525,9 +524,22 @@ export function StreamView({ }, []); useEffect(() => { + const screenshotShortcut = normalizeShortcut(shortcuts.screenshot); + const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "F11") { + const target = event.target as HTMLElement | null; + const isTyping = !!target && ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ); + if (isTyping) { + return; + } + + if (isShortcutMatch(event, screenshotShortcut)) { event.preventDefault(); + event.stopPropagation(); void captureScreenshot(); return; } @@ -547,9 +559,9 @@ export function StreamView({ } } }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [captureScreenshot, handleToggleSideBar]); + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [captureScreenshot, handleToggleSideBar, shortcuts.screenshot]); return (
@@ -661,7 +673,7 @@ export function StreamView({
Gallery - ScreensShot key: F11 + ScreensShot key: {shortcuts.screenshot}
ScreensShot @@ -677,6 +689,36 @@ export function StreamView({ {isSavingScreenshot ? "Capturing..." : "Capture"}
+
+
+ Screenshot Shortcut +
+ setScreenshotShortcutInput(event.target.value)} + onBlur={() => { + const normalized = normalizeShortcut(screenshotShortcutInput.trim()); + if (!normalized.valid) { + setScreenshotShortcutError(true); + return; + } + setScreenshotShortcutError(false); + setScreenshotShortcutInput(normalized.canonical); + if (normalized.canonical !== shortcuts.screenshot) { + onScreenshotShortcutChange(normalized.canonical); + } + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + (event.target as HTMLInputElement).blur(); + } + }} + placeholder="F11" + spellCheck={false} + /> +
{screenshots.length === 0 && ( - No screenshots yet. Press F11 to capture one. + No screenshots yet. Press {shortcuts.screenshot} to capture one. + )} + {screenshotShortcutError && ( + Invalid shortcut format. )} {galleryError && {galleryError}}
diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 1de2e91..5e8ed30 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -3124,6 +3124,9 @@ button.game-card-store-chip.active:hover { .sidebar-hint--error { color: #fda4af; } +.sidebar-shortcut-input { + width: 100%; +} .sidebar-button { border-radius: 4px; border: 1px solid var(--panel-border); diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index f99769b..84ea55a 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -40,6 +40,7 @@ export interface Settings { shortcutStopStream: string; shortcutToggleAntiAfk: string; shortcutToggleMicrophone: string; + shortcutScreenshot: string; microphoneMode: MicrophoneMode; microphoneDeviceId: string; hideStreamButtons: boolean; From 41af7fe6a6db4ba15a1f9212f9d4f191a5f547e1 Mon Sep 17 00:00:00 2001 From: Jared Date: Thu, 5 Mar 2026 00:27:44 -0600 Subject: [PATCH 09/17] feat(ui): enhance remaining playtime calculation with consumed hours in StreamView --- opennow-stable/src/renderer/src/App.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 035a664..36f5bdf 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -185,7 +185,10 @@ function warningMessage(code: StreamTimeWarning["code"]): string { return "Maximum session time approaching"; } -function formatRemainingPlaytimeFromSubscription(subscription: SubscriptionInfo | null): string { +function formatRemainingPlaytimeFromSubscription( + subscription: SubscriptionInfo | null, + consumedHours = 0, +): string { if (!subscription) { return "--"; } @@ -193,7 +196,8 @@ function formatRemainingPlaytimeFromSubscription(subscription: SubscriptionInfo return "Unlimited"; } - const safeHours = Math.max(0, Number.isFinite(subscription.remainingHours) ? subscription.remainingHours : 0); + const baseHours = Number.isFinite(subscription.remainingHours) ? subscription.remainingHours : 0; + const safeHours = Math.max(0, baseHours - Math.max(0, consumedHours)); const totalMinutes = Math.round(safeHours * 60); const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; @@ -1532,7 +1536,11 @@ export function App(): JSX.Element { } const showLaunchOverlay = streamStatus !== "idle" || launchError !== null; - const remainingPlaytimeText = formatRemainingPlaytimeFromSubscription(subscriptionInfo); + const consumedHours = + streamStatus === "streaming" + ? Math.floor(sessionElapsedSeconds / 60) / 60 + : 0; + const remainingPlaytimeText = formatRemainingPlaytimeFromSubscription(subscriptionInfo, consumedHours); // Show stream lifecycle (waiting/connecting/streaming/failure) if (showLaunchOverlay) { From 9a4d623916706561f39ec0827d5409730a5e2258 Mon Sep 17 00:00:00 2001 From: Jared Date: Thu, 5 Mar 2026 00:30:16 -0600 Subject: [PATCH 10/17] feat(ui): add periodic playtime resync from backend during streaming --- opennow-stable/src/renderer/src/App.tsx | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 36f5bdf..0f95483 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -38,6 +38,7 @@ const resolutionOptions = ["1280x720", "1920x1080", "2560x1440", "3840x2160", "2 const fpsOptions = [30, 60, 120, 144, 240]; const SESSION_READY_POLL_INTERVAL_MS = 2000; const SESSION_READY_TIMEOUT_MS = 180000; +const PLAYTIME_RESYNC_INTERVAL_MS = 5 * 60 * 1000; type GameSource = "main" | "library" | "public"; type AppPage = "home" | "library" | "settings"; @@ -716,6 +717,35 @@ export function App(): JSX.Element { return () => clearInterval(interval); }, [antiAfkEnabled, streamStatus]); + // Periodically re-sync subscription playtime from backend while streaming. + useEffect(() => { + if (streamStatus !== "streaming" || !authSession) { + return; + } + + let cancelled = false; + + const syncPlaytime = async (): Promise => { + try { + await loadSubscriptionInfo(authSession); + } catch (error) { + if (!cancelled) { + console.warn("Failed to re-sync subscription playtime:", error); + } + } + }; + + void syncPlaytime(); + const timer = window.setInterval(() => { + void syncPlaytime(); + }, PLAYTIME_RESYNC_INTERVAL_MS); + + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [authSession, loadSubscriptionInfo, streamStatus]); + // Restore focus to video element when navigating away from Settings during streaming useEffect(() => { if (streamStatus === "streaming" && currentPage !== "settings" && videoRef.current) { From 1b048bbd079b051cd44f70f80e6fc2b793ff75af Mon Sep 17 00:00:00 2001 From: Jared Date: Thu, 5 Mar 2026 00:42:27 -0600 Subject: [PATCH 11/17] feat(settings): add mouse acceleration setting and related functionality --- opennow-stable/src/main/settings.ts | 3 ++ opennow-stable/src/renderer/src/App.tsx | 15 ++++++++++ .../renderer/src/components/SettingsPage.tsx | 15 ++++++++++ .../renderer/src/components/StreamView.tsx | 28 +++++++++++++++++++ .../src/renderer/src/gfn/webrtcClient.ts | 27 ++++++++++++++++-- opennow-stable/src/shared/gfn.ts | 1 + 6 files changed, 86 insertions(+), 3 deletions(-) diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index d5edc37..c348385 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -20,6 +20,8 @@ export interface Settings { clipboardPaste: boolean; /** Mouse sensitivity multiplier */ mouseSensitivity: number; + /** Apply software mouse acceleration on top of sensitivity */ + mouseAcceleration: boolean; /** Toggle stats overlay shortcut */ shortcutToggleStats: string; /** Toggle pointer lock shortcut */ @@ -63,6 +65,7 @@ const DEFAULT_SETTINGS: Settings = { region: "", clipboardPaste: false, mouseSensitivity: 1, + mouseAcceleration: false, shortcutToggleStats: "F3", shortcutTogglePointerLock: "F8", shortcutStopStream: defaultStopShortcut, diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 0f95483..452e85f 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -328,6 +328,7 @@ export function App(): JSX.Element { region: "", clipboardPaste: false, mouseSensitivity: 1, + mouseAcceleration: false, shortcutToggleStats: DEFAULT_SHORTCUTS.shortcutToggleStats, shortcutTogglePointerLock: DEFAULT_SHORTCUTS.shortcutTogglePointerLock, shortcutStopStream: DEFAULT_SHORTCUTS.shortcutStopStream, @@ -830,6 +831,7 @@ export function App(): JSX.Element { microphoneMode: settings.microphoneMode, microphoneDeviceId: settings.microphoneDeviceId || undefined, mouseSensitivity: settings.mouseSensitivity, + mouseAcceleration: settings.mouseAcceleration, onLog: (line: string) => console.log(`[WebRTC] ${line}`), onStats: (stats) => setDiagnostics(stats), onEscHoldProgress: (visible, progress) => { @@ -897,6 +899,13 @@ export function App(): JSX.Element { // ignore } } + if (key === "mouseAcceleration") { + try { + (clientRef.current as any)?.setMouseAccelerationEnabled?.(value as boolean); + } catch { + // ignore + } + } if (key === "maxBitrateMbps") { try { void (clientRef.current as any)?.setMaxBitrateKbps?.((value as number) * 1000); @@ -910,6 +919,10 @@ export function App(): JSX.Element { void updateSetting("mouseSensitivity", value); }, [updateSetting]); + const handleMouseAccelerationChange = useCallback((value: boolean) => { + void updateSetting("mouseAcceleration", value); + }, [updateSetting]); + const handleMicrophoneModeChange = useCallback((value: import("@shared/gfn").MicrophoneMode) => { void updateSetting("microphoneMode", value); }, [updateSetting]); @@ -1620,6 +1633,8 @@ export function App(): JSX.Element { }} mouseSensitivity={settings.mouseSensitivity} onMouseSensitivityChange={handleMouseSensitivityChange} + mouseAcceleration={settings.mouseAcceleration} + onMouseAccelerationChange={handleMouseAccelerationChange} microphoneMode={settings.microphoneMode} onMicrophoneModeChange={handleMicrophoneModeChange} onScreenshotShortcutChange={(value) => { diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index 5ea607b..9f124ae 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -1466,6 +1466,21 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag Multiplier applied to mouse movement (1.00 = default)
+
+ + +
+
diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index 5aae7c4..ce02bbb 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -50,6 +50,8 @@ interface StreamViewProps { onToggleMicrophone?: () => void; mouseSensitivity: number; onMouseSensitivityChange: (value: number) => void; + mouseAcceleration: boolean; + onMouseAccelerationChange: (value: boolean) => void; onRequestPointerLock?: () => void; onReleasePointerLock?: () => void; microphoneMode: MicrophoneMode; @@ -224,6 +226,8 @@ export function StreamView({ onToggleMicrophone, mouseSensitivity, onMouseSensitivityChange, + mouseAcceleration, + onMouseAccelerationChange, onRequestPointerLock, onReleasePointerLock, microphoneMode, @@ -621,7 +625,31 @@ export function StreamView({ /> Multiplier applied to mouse movement (1.00 = default).
+
+
+ Mouse Accelerator + {mouseAcceleration ? "Enabled" : "Disabled"} +
+
+ + +
+ Boosts large/faster turns while keeping fine movement precise. +
+