From 846e022322b004c8697f016350e20dca7bfc5edc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Apr 2026 21:50:05 +0000 Subject: [PATCH] fix(settings): easier keybind capture, conflicts, and recording shortcut - Add press-to-bind and paste support for shortcut fields (read-only inputs) - Detect duplicate shortcuts and reserve sidebar toggle (Meta+G / Ctrl+Shift+G) - Expose recording shortcut in settings; include anti-AFK in stream conflict checks - Export shortcutFromKeyboardEvent helper; align sidebar hint with canonical Meta+G Co-authored-by: Zortos --- opennow-stable/src/renderer/src/App.tsx | 1 + .../renderer/src/components/SettingsPage.tsx | 396 ++++++++++++++---- .../renderer/src/components/StreamView.tsx | 148 +++++-- opennow-stable/src/renderer/src/shortcuts.ts | 43 ++ opennow-stable/src/renderer/src/styles.css | 4 + 5 files changed, 474 insertions(+), 118 deletions(-) diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 89373b3a..4e3dc7bc 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -3091,6 +3091,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 83225bf9..b812f75b 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 { @@ -113,8 +113,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" }, @@ -413,12 +440,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); @@ -455,6 +484,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; @@ -720,30 +753,159 @@ 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; + } + + 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; } - setError(false); - setInput(normalized.canonical); + + 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 handleShortcutKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === "Enter") { - (e.target as HTMLInputElement).blur(); + 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 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( () => settings.shortcutToggleStats === shortcutDefaults.shortcutToggleStats @@ -751,7 +913,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, @@ -759,6 +922,7 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, settings.shortcutToggleAntiAfk, settings.shortcutToggleMicrophone, settings.shortcutScreenshot, + settings.shortcutToggleRecording, ] ); @@ -769,23 +933,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); @@ -1807,109 +1964,168 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults,
-
- {(toggleStatsError || togglePointerLockError || stopStreamError || toggleAntiAfkError || toggleMicrophoneError || screenshotError) && ( + {(toggleStatsError || togglePointerLockError || stopStreamError || toggleAntiAfkError || toggleMicrophoneError || screenshotError || recordingError) && ( - Invalid shortcut. Use {shortcutExamples} + {toggleStatsError + || togglePointerLockError + || stopStreamError + || toggleAntiAfkError + || toggleMicrophoneError + || screenshotError + || recordingError} )} - {!toggleStatsError && !togglePointerLockError && !stopStreamError && !toggleAntiAfkError && !toggleMicrophoneError && !screenshotError && ( + {!toggleStatsError && !togglePointerLockError && !stopStreamError && !toggleAntiAfkError && !toggleMicrophoneError && !screenshotError && !recordingError && ( - {shortcutExamples}. Stop: {formatShortcutForDisplay(settings.shortcutStopStream, isMac)}. Mic: {formatShortcutForDisplay(settings.shortcutToggleMicrophone, isMac)}. ScreensShot: {formatShortcutForDisplay(settings.shortcutScreenshot, isMac)}. + 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 ea79028c..c93f1458 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; @@ -845,9 +846,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)) @@ -859,7 +861,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(); @@ -876,9 +878,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)) @@ -890,7 +893,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); @@ -1646,11 +1748,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) { @@ -1668,12 +1768,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} /> @@ -1686,11 +1783,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) { @@ -1708,12 +1803,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} /> @@ -1738,7 +1830,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 4f369405..0c2c739d 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -2435,6 +2435,10 @@ button.game-card-store-chip.active:hover { text-align: right; } +.settings-shortcut-input[readonly]:not(.settings-shortcut-input--static) { + cursor: pointer; +} + .settings-shortcut-input--static { background: var(--bg-e); color: var(--ink);