diff --git a/opennow-stable/package-lock.json b/opennow-stable/package-lock.json index c5615bcf..8f457369 100644 --- a/opennow-stable/package-lock.json +++ b/opennow-stable/package-lock.json @@ -61,7 +61,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -737,6 +736,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -758,6 +758,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -774,6 +775,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -788,6 +790,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2141,7 +2144,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2273,7 +2275,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2720,7 +2721,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3238,7 +3238,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -3498,7 +3499,6 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -3837,6 +3837,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -3857,6 +3858,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -4680,7 +4682,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -5302,6 +5304,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -5765,6 +5768,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -5782,6 +5786,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -5792,7 +5797,6 @@ "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -5897,7 +5901,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5907,7 +5910,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6095,6 +6097,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -6127,7 +6130,6 @@ "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6192,7 +6194,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/sanitize-filename": { @@ -6565,6 +6567,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -6858,7 +6861,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 0910db97..939389d1 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -89,8 +89,8 @@ const resolutionOptions = getResolutionsByAspectRatio("16:9"); function getAppStyle(posterSizeScale: number): CSSProperties { return { - ["--game-poster-scale" as "--game-poster-scale"]: String(posterSizeScale), - }; + "--game-poster-scale": String(posterSizeScale), + } as CSSProperties; } const SESSION_READY_POLL_INTERVAL_MS = 2000; const SESSION_AD_POLL_INTERVAL_MS = 30000; @@ -810,6 +810,7 @@ export function App(): JSX.Element { enableL4S: false, enableCloudGsync: false, discordRichPresence: false, + posterSizeScale: 1, }); const [settingsLoaded, setSettingsLoaded] = useState(false); const [codecResults, setCodecResults] = useState(() => loadStoredCodecResults()); @@ -3235,6 +3236,7 @@ export function App(): JSX.Element { toggleStats: formatShortcutForDisplay(settings.shortcutToggleStats, isMac), togglePointerLock: formatShortcutForDisplay(settings.shortcutTogglePointerLock, isMac), stopStream: formatShortcutForDisplay(settings.shortcutStopStream, isMac), + toggleAntiAfk: shortcuts.toggleAntiAfk.canonical, toggleMicrophone: formatShortcutForDisplay(settings.shortcutToggleMicrophone, isMac), screenshot: shortcuts.screenshot.canonical, recording: shortcuts.recording.canonical, diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index 94a8a411..ee6fa7e1 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -23,7 +23,7 @@ import { USER_FACING_COLOR_QUALITY_OPTIONS, USER_FACING_VIDEO_CODEC_OPTIONS, } from "@shared/gfn"; -import { formatShortcutForDisplay, normalizeShortcut } from "../shortcuts"; +import { formatShortcutForDisplay, normalizeShortcut, shortcutFromKeyboardEvent } from "../shortcuts"; import { getCodecDecodeBadgeState, type CodecTestResult } from "../lib/codecDiagnostics"; interface SettingsPageProps { @@ -119,8 +119,35 @@ const shortcutDefaults = { shortcutToggleAntiAfk: "Ctrl+Shift+K", shortcutToggleMicrophone: "Ctrl+Shift+M", shortcutScreenshot: "F11", + shortcutToggleRecording: "F12", } as const; +/** Canonical shortcut for toggling the stream sidebar (must match StreamView key handler). */ +const SIDEBAR_TOGGLE_SHORTCUT_RAW = isMac ? "Meta+G" : "Ctrl+Shift+G"; + +type ShortcutSettingKey = keyof typeof shortcutDefaults; + +const SHORTCUT_SETTING_KEYS = Object.keys(shortcutDefaults) as ShortcutSettingKey[]; + +function getShortcutConflictMessage( + editingKey: ShortcutSettingKey, + candidateCanonical: string, + currentSettings: Settings, +): string | null { + const sidebarParsed = normalizeShortcut(SIDEBAR_TOGGLE_SHORTCUT_RAW); + if (sidebarParsed.valid && candidateCanonical === sidebarParsed.canonical) { + return "Shortcut conflicts with the settings sidebar toggle."; + } + for (const key of SHORTCUT_SETTING_KEYS) { + if (key === editingKey) continue; + const parsed = normalizeShortcut(currentSettings[key]); + if (parsed.valid && parsed.canonical === candidateCanonical) { + return "Shortcut conflicts with another binding."; + } + } + return null; +} + const microphoneModeOptions: Array<{ value: MicrophoneMode; label: string }> = [ { value: "disabled", label: "Disabled" }, { value: "push-to-talk", label: "Push-to-Talk" }, @@ -419,12 +446,14 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, 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); + const [recordingInput, setRecordingInput] = useState(settings.shortcutToggleRecording); + const [toggleStatsError, setToggleStatsError] = useState(null); + const [togglePointerLockError, setTogglePointerLockError] = useState(null); + const [stopStreamError, setStopStreamError] = useState(null); + const [toggleAntiAfkError, setToggleAntiAfkError] = useState(null); + const [toggleMicrophoneError, setToggleMicrophoneError] = useState(null); + const [screenshotError, setScreenshotError] = useState(null); + const [recordingError, setRecordingError] = useState(null); const [keyboardLayoutDropdownOpen, setKeyboardLayoutDropdownOpen] = useState(false); const keyboardLayoutDropdownRef = useRef(null); @@ -466,6 +495,10 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, setScreenshotInput(settings.shortcutScreenshot); }, [settings.shortcutScreenshot]); + useEffect(() => { + setRecordingInput(settings.shortcutToggleRecording); + }, [settings.shortcutToggleRecording]); + // Fetch subscription data (cached per account; reload only when account changes) useEffect(() => { let cancelled = false; @@ -747,28 +780,157 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, return () => document.removeEventListener("mousedown", handlePointerDown); }, []); - const handleShortcutBlur = ( - key: K, - rawValue: string, - setInput: (value: string) => void, - setError: (value: boolean) => void - ): void => { - const normalized = normalizeShortcut(rawValue.trim()); + const handleShortcutBlur = (key: ShortcutSettingKey, rawValue: string): void => { + const trimmed = rawValue.trim(); + if (!trimmed) { + const msg = "Shortcut cannot be empty."; + switch (key) { + case "shortcutToggleStats": setToggleStatsError(msg); break; + case "shortcutTogglePointerLock": setTogglePointerLockError(msg); break; + case "shortcutStopStream": setStopStreamError(msg); break; + case "shortcutToggleAntiAfk": setToggleAntiAfkError(msg); break; + case "shortcutToggleMicrophone": setToggleMicrophoneError(msg); break; + case "shortcutScreenshot": setScreenshotError(msg); break; + case "shortcutToggleRecording": setRecordingError(msg); break; + } + return; + } + + const normalized = normalizeShortcut(trimmed); if (!normalized.valid) { - setError(true); + const msg = "Invalid shortcut format."; + switch (key) { + case "shortcutToggleStats": setToggleStatsError(msg); break; + case "shortcutTogglePointerLock": setTogglePointerLockError(msg); break; + case "shortcutStopStream": setStopStreamError(msg); break; + case "shortcutToggleAntiAfk": setToggleAntiAfkError(msg); break; + case "shortcutToggleMicrophone": setToggleMicrophoneError(msg); break; + case "shortcutScreenshot": setScreenshotError(msg); break; + case "shortcutToggleRecording": setRecordingError(msg); break; + } return; } - setError(false); - setInput(normalized.canonical); + + const conflict = getShortcutConflictMessage(key, normalized.canonical, settings); + if (conflict) { + switch (key) { + case "shortcutToggleStats": setToggleStatsError(conflict); break; + case "shortcutTogglePointerLock": setTogglePointerLockError(conflict); break; + case "shortcutStopStream": setStopStreamError(conflict); break; + case "shortcutToggleAntiAfk": setToggleAntiAfkError(conflict); break; + case "shortcutToggleMicrophone": setToggleMicrophoneError(conflict); break; + case "shortcutScreenshot": setScreenshotError(conflict); break; + case "shortcutToggleRecording": setRecordingError(conflict); break; + } + return; + } + + switch (key) { + case "shortcutToggleStats": setToggleStatsError(null); break; + case "shortcutTogglePointerLock": setTogglePointerLockError(null); break; + case "shortcutStopStream": setStopStreamError(null); break; + case "shortcutToggleAntiAfk": setToggleAntiAfkError(null); break; + case "shortcutToggleMicrophone": setToggleMicrophoneError(null); break; + case "shortcutScreenshot": setScreenshotError(null); break; + case "shortcutToggleRecording": setRecordingError(null); break; + } + + switch (key) { + case "shortcutToggleStats": setToggleStatsInput(normalized.canonical); break; + case "shortcutTogglePointerLock": setTogglePointerLockInput(normalized.canonical); break; + case "shortcutStopStream": setStopStreamInput(normalized.canonical); break; + case "shortcutToggleAntiAfk": setToggleAntiAfkInput(normalized.canonical); break; + case "shortcutToggleMicrophone": setToggleMicrophoneInput(normalized.canonical); break; + case "shortcutScreenshot": setScreenshotInput(normalized.canonical); break; + case "shortcutToggleRecording": setRecordingInput(normalized.canonical); break; + } + if (settings[key] !== normalized.canonical) { - handleChange(key, normalized.canonical as Settings[K]); + handleChange(key, normalized.canonical); + } + }; + + const applyShortcutCapture = (key: ShortcutSettingKey, canonical: string): void => { + const conflict = getShortcutConflictMessage(key, canonical, settings); + if (conflict) { + switch (key) { + case "shortcutToggleStats": setToggleStatsError(conflict); break; + case "shortcutTogglePointerLock": setTogglePointerLockError(conflict); break; + case "shortcutStopStream": setStopStreamError(conflict); break; + case "shortcutToggleAntiAfk": setToggleAntiAfkError(conflict); break; + case "shortcutToggleMicrophone": setToggleMicrophoneError(conflict); break; + case "shortcutScreenshot": setScreenshotError(conflict); break; + case "shortcutToggleRecording": setRecordingError(conflict); break; + } + return; + } + + switch (key) { + case "shortcutToggleStats": setToggleStatsError(null); break; + case "shortcutTogglePointerLock": setTogglePointerLockError(null); break; + case "shortcutStopStream": setStopStreamError(null); break; + case "shortcutToggleAntiAfk": setToggleAntiAfkError(null); break; + case "shortcutToggleMicrophone": setToggleMicrophoneError(null); break; + case "shortcutScreenshot": setScreenshotError(null); break; + case "shortcutToggleRecording": setRecordingError(null); break; + } + + switch (key) { + case "shortcutToggleStats": setToggleStatsInput(canonical); break; + case "shortcutTogglePointerLock": setTogglePointerLockInput(canonical); break; + case "shortcutStopStream": setStopStreamInput(canonical); break; + case "shortcutToggleAntiAfk": setToggleAntiAfkInput(canonical); break; + case "shortcutToggleMicrophone": setToggleMicrophoneInput(canonical); break; + case "shortcutScreenshot": setScreenshotInput(canonical); break; + case "shortcutToggleRecording": setRecordingInput(canonical); break; + } + + if (settings[key] !== canonical) { + handleChange(key, canonical); + } + }; + + const handleShortcutCaptureKeyDown = (key: ShortcutSettingKey, e: React.KeyboardEvent): void => { + if (e.key === "Escape") { + e.preventDefault(); + e.currentTarget.blur(); + return; + } + if (e.key === "Enter" || e.key === "Tab") { + return; + } + + const captured = shortcutFromKeyboardEvent(e.nativeEvent); + if (!captured) { + return; } + + e.preventDefault(); + e.stopPropagation(); + applyShortcutCapture(key, captured); }; - const handleShortcutKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === "Enter") { - (e.target as HTMLInputElement).blur(); + const handleShortcutPaste = (key: ShortcutSettingKey, e: React.ClipboardEvent): void => { + const text = e.clipboardData.getData("text/plain").trim(); + if (!text) { + return; + } + e.preventDefault(); + const normalized = normalizeShortcut(text); + if (!normalized.valid) { + const msg = "Invalid shortcut format."; + switch (key) { + case "shortcutToggleStats": setToggleStatsError(msg); break; + case "shortcutTogglePointerLock": setTogglePointerLockError(msg); break; + case "shortcutStopStream": setStopStreamError(msg); break; + case "shortcutToggleAntiAfk": setToggleAntiAfkError(msg); break; + case "shortcutToggleMicrophone": setToggleMicrophoneError(msg); break; + case "shortcutScreenshot": setScreenshotError(msg); break; + case "shortcutToggleRecording": setRecordingError(msg); break; + } + return; } + applyShortcutCapture(key, normalized.canonical); }; const areShortcutsDefault = useMemo( @@ -778,7 +940,8 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, && settings.shortcutStopStream === shortcutDefaults.shortcutStopStream && settings.shortcutToggleAntiAfk === shortcutDefaults.shortcutToggleAntiAfk && settings.shortcutToggleMicrophone === shortcutDefaults.shortcutToggleMicrophone - && settings.shortcutScreenshot === shortcutDefaults.shortcutScreenshot, + && settings.shortcutScreenshot === shortcutDefaults.shortcutScreenshot + && settings.shortcutToggleRecording === shortcutDefaults.shortcutToggleRecording, [ settings.shortcutToggleStats, settings.shortcutTogglePointerLock, @@ -786,6 +949,7 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, settings.shortcutToggleAntiAfk, settings.shortcutToggleMicrophone, settings.shortcutScreenshot, + settings.shortcutToggleRecording, ] ); @@ -796,23 +960,16 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, setToggleAntiAfkInput(shortcutDefaults.shortcutToggleAntiAfk); setToggleMicrophoneInput(shortcutDefaults.shortcutToggleMicrophone); setScreenshotInput(shortcutDefaults.shortcutScreenshot); - setToggleStatsError(false); - setTogglePointerLockError(false); - setStopStreamError(false); - setToggleAntiAfkError(false); - setToggleMicrophoneError(false); - setScreenshotError(false); - - const shortcutKeys = [ - "shortcutToggleStats", - "shortcutTogglePointerLock", - "shortcutStopStream", - "shortcutToggleAntiAfk", - "shortcutToggleMicrophone", - "shortcutScreenshot", - ] as const; - - for (const key of shortcutKeys) { + setRecordingInput(shortcutDefaults.shortcutToggleRecording); + setToggleStatsError(null); + setTogglePointerLockError(null); + setStopStreamError(null); + setToggleAntiAfkError(null); + setToggleMicrophoneError(null); + setScreenshotError(null); + setRecordingError(null); + + for (const key of SHORTCUT_SETTING_KEYS) { const value = shortcutDefaults[key]; if (settings[key] !== value) { handleChange(key, value); @@ -1883,112 +2040,170 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults,
- +
+ Toggle Stats + e.target.select()} + onBlur={() => handleShortcutBlur("shortcutToggleStats", toggleStatsInput)} + onPaste={(e) => handleShortcutPaste("shortcutToggleStats", e)} + onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutToggleStats", e)} + placeholder="Click here, then press a key" + title="Focus and press the key combination to bind" + spellCheck={false} + /> +
- +
+ Mouse Lock + e.target.select()} + onBlur={() => handleShortcutBlur("shortcutTogglePointerLock", togglePointerLockInput)} + onPaste={(e) => handleShortcutPaste("shortcutTogglePointerLock", e)} + onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutTogglePointerLock", e)} + placeholder="Click here, then press a key" + title="Focus and press the key combination to bind" + spellCheck={false} + /> +
- +
+ Stop Stream + e.target.select()} + onBlur={() => handleShortcutBlur("shortcutStopStream", stopStreamInput)} + onPaste={(e) => handleShortcutPaste("shortcutStopStream", e)} + onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutStopStream", e)} + placeholder="Click here, then press a key" + title="Focus and press the key combination to bind" + spellCheck={false} + /> +
- +
+ Toggle Anti-AFK + e.target.select()} + onBlur={() => handleShortcutBlur("shortcutToggleAntiAfk", toggleAntiAfkInput)} + onPaste={(e) => handleShortcutPaste("shortcutToggleAntiAfk", e)} + onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutToggleAntiAfk", e)} + placeholder="Click here, then press a key" + title="Focus and press the key combination to bind" + spellCheck={false} + /> +
- +
+ Toggle Microphone + e.target.select()} + onBlur={() => handleShortcutBlur("shortcutToggleMicrophone", toggleMicrophoneInput)} + onPaste={(e) => handleShortcutPaste("shortcutToggleMicrophone", e)} + onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutToggleMicrophone", e)} + placeholder="Click here, then press a key" + title="Focus and press the key combination to bind" + spellCheck={false} + /> +
- +
+ Screenshot + e.target.select()} + onBlur={() => handleShortcutBlur("shortcutScreenshot", screenshotInput)} + onPaste={(e) => handleShortcutPaste("shortcutScreenshot", e)} + onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutScreenshot", e)} + placeholder="Click here, then press a key" + title="Focus and press the key combination to bind" + spellCheck={false} + /> +
- -
+
+ Recording + e.target.select()} + onBlur={() => handleShortcutBlur("shortcutToggleRecording", recordingInput)} + onPaste={(e) => handleShortcutPaste("shortcutToggleRecording", e)} + onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutToggleRecording", e)} + placeholder="Click here, then press a key" + title="Focus and press the key combination to bind" + spellCheck={false} + /> +
- {(toggleStatsError || togglePointerLockError || stopStreamError || toggleAntiAfkError || toggleMicrophoneError || screenshotError) && ( - - Invalid shortcut. Use {shortcutExamples} - - )} +
+ Toggle stream sidebar + +
+ - {!toggleStatsError && !togglePointerLockError && !stopStreamError && !toggleAntiAfkError && !toggleMicrophoneError && !screenshotError && ( - - {shortcutExamples}. Stop: {formatShortcutForDisplay(settings.shortcutStopStream, isMac)}. Mic: {formatShortcutForDisplay(settings.shortcutToggleMicrophone, isMac)}. Screenshot: {formatShortcutForDisplay(settings.shortcutScreenshot, isMac)}. - - )} + {(toggleStatsError || togglePointerLockError || stopStreamError || toggleAntiAfkError || toggleMicrophoneError || screenshotError || recordingError) && ( + + {toggleStatsError + || togglePointerLockError + || stopStreamError + || toggleAntiAfkError + || toggleMicrophoneError + || screenshotError + || recordingError} + + )} + + {!toggleStatsError && !togglePointerLockError && !stopStreamError && !toggleAntiAfkError && !toggleMicrophoneError && !screenshotError && !recordingError && ( + + Click a field and press the keys to bind, or paste a shortcut ({shortcutExamples}). Escape cancels focus. Stop: {formatShortcutForDisplay(settings.shortcutStopStream, isMac)}. Mic: {formatShortcutForDisplay(settings.shortcutToggleMicrophone, isMac)}. Screenshot: {formatShortcutForDisplay(settings.shortcutScreenshot, isMac)}. Recording: {formatShortcutForDisplay(settings.shortcutToggleRecording, isMac)}. + + )} diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index 50867f4f..b11785d6 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -10,7 +10,7 @@ import type { MicState } from "../gfn/microphoneManager"; import { getStoreDisplayName, getStoreIconComponent } from "./GameCard"; import { RemainingPlaytimeIndicator, SessionElapsedIndicator } from "./ElapsedSessionIndicators"; import type { MicrophoneMode, ScreenshotEntry, RecordingEntry, SubscriptionInfo } from "@shared/gfn"; -import { isShortcutMatch, normalizeShortcut } from "../shortcuts"; +import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut, shortcutFromKeyboardEvent } from "../shortcuts"; interface StreamViewProps { videoRef: React.Ref; @@ -21,6 +21,7 @@ interface StreamViewProps { toggleStats: string; togglePointerLock: string; stopStream: string; + toggleAntiAfk: string; toggleMicrophone?: string; screenshot: string; recording: string; @@ -851,9 +852,10 @@ export function StreamView({ shortcuts.toggleStats, shortcuts.togglePointerLock, shortcuts.stopStream, + shortcuts.toggleAntiAfk, shortcuts.toggleMicrophone, shortcuts.recording, - isMacClient ? "Cmd+G" : "Ctrl+Shift+G", + isMacClient ? "Meta+G" : "Ctrl+Shift+G", ] .filter((value): value is string => typeof value === "string" && value.trim().length > 0) .map((value) => normalizeShortcut(value)) @@ -865,7 +867,7 @@ export function StreamView({ } return null; - }, [isMacClient, shortcuts.recording, shortcuts.stopStream, shortcuts.toggleMicrophone, shortcuts.togglePointerLock, shortcuts.toggleStats]); + }, [isMacClient, shortcuts.recording, shortcuts.stopStream, shortcuts.toggleAntiAfk, shortcuts.toggleMicrophone, shortcuts.togglePointerLock, shortcuts.toggleStats]); const getRecordingShortcutError = useCallback((rawValue: string): string | null => { const trimmed = rawValue.trim(); @@ -882,9 +884,10 @@ export function StreamView({ shortcuts.toggleStats, shortcuts.togglePointerLock, shortcuts.stopStream, + shortcuts.toggleAntiAfk, shortcuts.toggleMicrophone, shortcuts.screenshot, - isMacClient ? "Cmd+G" : "Ctrl+Shift+G", + isMacClient ? "Meta+G" : "Ctrl+Shift+G", ] .filter((value): value is string => typeof value === "string" && value.trim().length > 0) .map((value) => normalizeShortcut(value)) @@ -896,7 +899,106 @@ export function StreamView({ } return null; - }, [isMacClient, shortcuts.screenshot, shortcuts.stopStream, shortcuts.toggleMicrophone, shortcuts.togglePointerLock, shortcuts.toggleStats]); + }, [isMacClient, shortcuts.screenshot, shortcuts.stopStream, shortcuts.toggleAntiAfk, shortcuts.toggleMicrophone, shortcuts.togglePointerLock, shortcuts.toggleStats]); + + const SIDEBAR_TOGGLE_RAW = isMacClient ? "Meta+G" : "Ctrl+Shift+G"; + const sidebarToggleShortcutDisplay = formatShortcutForDisplay(SIDEBAR_TOGGLE_RAW, isMacClient); + + const applyScreenshotShortcutFromCapture = useCallback( + (canonical: string) => { + const error = getScreenshotShortcutError(canonical); + if (error) { + setScreenshotShortcutError(error); + return; + } + const normalized = normalizeShortcut(canonical.trim()); + if (!normalized.valid) { + setScreenshotShortcutError("Invalid shortcut format."); + return; + } + setScreenshotShortcutError(null); + setScreenshotShortcutInput(normalized.canonical); + if (normalized.canonical !== shortcuts.screenshot) { + onScreenshotShortcutChange(normalized.canonical); + } + }, + [getScreenshotShortcutError, onScreenshotShortcutChange, shortcuts.screenshot], + ); + + const applyRecordingShortcutFromCapture = useCallback( + (canonical: string) => { + const error = getRecordingShortcutError(canonical); + if (error) { + setRecordingShortcutError(error); + return; + } + const normalized = normalizeShortcut(canonical.trim()); + if (!normalized.valid) { + setRecordingShortcutError("Invalid shortcut format."); + return; + } + setRecordingShortcutError(null); + setRecordingShortcutInput(normalized.canonical); + if (normalized.canonical !== shortcuts.recording) { + onRecordingShortcutChange(normalized.canonical); + } + }, + [getRecordingShortcutError, onRecordingShortcutChange, shortcuts.recording], + ); + + const handleStreamScreenshotShortcutKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === "Escape") { + e.preventDefault(); + e.currentTarget.blur(); + return; + } + if (e.key === "Enter" || e.key === "Tab") { + return; + } + const captured = shortcutFromKeyboardEvent(e.nativeEvent); + if (!captured) { + return; + } + e.preventDefault(); + e.stopPropagation(); + applyScreenshotShortcutFromCapture(captured); + }; + + const handleStreamRecordingShortcutKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === "Escape") { + e.preventDefault(); + e.currentTarget.blur(); + return; + } + if (e.key === "Enter" || e.key === "Tab") { + return; + } + const captured = shortcutFromKeyboardEvent(e.nativeEvent); + if (!captured) { + return; + } + e.preventDefault(); + e.stopPropagation(); + applyRecordingShortcutFromCapture(captured); + }; + + const handleStreamScreenshotShortcutPaste = (e: React.ClipboardEvent): void => { + const text = e.clipboardData.getData("text/plain").trim(); + if (!text) { + return; + } + e.preventDefault(); + applyScreenshotShortcutFromCapture(text); + }; + + const handleStreamRecordingShortcutPaste = (e: React.ClipboardEvent): void => { + const text = e.clipboardData.getData("text/plain").trim(); + if (!text) { + return; + } + e.preventDefault(); + applyRecordingShortcutFromCapture(text); + }; const refreshScreenshots = useCallback(async () => { setGalleryError(null); @@ -1652,11 +1754,9 @@ export function StreamView({ type="text" className={`settings-text-input settings-shortcut-input sidebar-shortcut-input ${screenshotShortcutError ? "error" : ""}`} value={screenshotShortcutInput} - onChange={(event) => { - const nextValue = event.target.value; - setScreenshotShortcutInput(nextValue); - setScreenshotShortcutError(getScreenshotShortcutError(nextValue)); - }} + readOnly + onFocus={(event) => event.target.select()} + onPaste={handleStreamScreenshotShortcutPaste} onBlur={() => { const error = getScreenshotShortcutError(screenshotShortcutInput); if (error) { @@ -1674,12 +1774,9 @@ export function StreamView({ onScreenshotShortcutChange(normalized.canonical); } }} - onKeyDown={(event) => { - if (event.key === "Enter") { - (event.target as HTMLInputElement).blur(); - } - }} - placeholder="F11" + onKeyDown={handleStreamScreenshotShortcutKeyDown} + placeholder="Click, then press a key" + title="Focus and press the key combination to bind" spellCheck={false} /> @@ -1692,11 +1789,9 @@ export function StreamView({ type="text" className={`settings-text-input settings-shortcut-input sidebar-shortcut-input ${recordingShortcutError ? "error" : ""}`} value={recordingShortcutInput} - onChange={(event) => { - const nextValue = event.target.value; - setRecordingShortcutInput(nextValue); - setRecordingShortcutError(getRecordingShortcutError(nextValue)); - }} + readOnly + onFocus={(event) => event.target.select()} + onPaste={handleStreamRecordingShortcutPaste} onBlur={() => { const error = getRecordingShortcutError(recordingShortcutInput); if (error) { @@ -1714,12 +1809,9 @@ export function StreamView({ onRecordingShortcutChange(normalized.canonical); } }} - onKeyDown={(event) => { - if (event.key === "Enter") { - (event.target as HTMLInputElement).blur(); - } - }} - placeholder="F12" + onKeyDown={handleStreamRecordingShortcutKeyDown} + placeholder="Click, then press a key" + title="Focus and press the key combination to bind" spellCheck={false} /> @@ -1744,7 +1836,7 @@ export function StreamView({ )}
Toggle Sidebar - {isMacClient ? "Cmd+G" : "Ctrl+Shift+G"} + {sidebarToggleShortcutDisplay}
diff --git a/opennow-stable/src/renderer/src/shortcuts.ts b/opennow-stable/src/renderer/src/shortcuts.ts index e7fe18f8..11f8e454 100644 --- a/opennow-stable/src/renderer/src/shortcuts.ts +++ b/opennow-stable/src/renderer/src/shortcuts.ts @@ -210,3 +210,46 @@ export function formatShortcutForDisplay(raw: string, isMac: boolean): string { parts.push(parsed.key); return parts.join("+"); } + +const MODIFIER_ONLY_CODES = new Set([ + "ControlLeft", + "ControlRight", + "AltLeft", + "AltRight", + "ShiftLeft", + "ShiftRight", + "MetaLeft", + "MetaRight", + "OSLeft", + "OSRight", +]); + +/** + * Builds a canonical shortcut string from a keydown event (for press-to-bind UIs). + * Returns null for modifier-only keys, unknown keys, or invalid combinations. + */ +export function shortcutFromKeyboardEvent(event: KeyboardEvent): string | null { + if (event.repeat) { + return null; + } + if (MODIFIER_ONLY_CODES.has(event.code)) { + return null; + } + + const fromCode = normalizeEventCode(event.code); + const fromKey = normalizeKeyToken(normalizeEventKey(event.key)); + const keyToken = fromCode ?? fromKey; + if (!keyToken) { + return null; + } + + const parts: string[] = []; + if (event.ctrlKey) parts.push("Ctrl"); + if (event.altKey) parts.push("Alt"); + if (event.shiftKey) parts.push("Shift"); + if (event.metaKey) parts.push("Meta"); + parts.push(keyToken); + + const parsed = normalizeShortcut(parts.join("+")); + return parsed.valid ? parsed.canonical : null; +} diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 19e5ea7e..7bf74f8f 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -2785,6 +2785,10 @@ button.game-card-store-chip.active:hover { text-align: left; } +.settings-shortcut-input[readonly]:not(.settings-shortcut-input--static) { + cursor: pointer; +} + .settings-shortcut-input--static { background: var(--bg-e); color: var(--ink);