From 8f92bf33c3011b7618f8b1ab2cf6d0c34935b880 Mon Sep 17 00:00:00 2001 From: roormonger <34205054+roormonger@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:38:18 -0400 Subject: [PATCH] even more navigation refinements --- src/components/GamesPage.tsx | 171 +++++++++++++++++++++++++++++------ src/hooks/useGamepad.ts | 15 +++ src/index.css | 10 +- 3 files changed, 166 insertions(+), 30 deletions(-) diff --git a/src/components/GamesPage.tsx b/src/components/GamesPage.tsx index d1a70d9..92b5582 100644 --- a/src/components/GamesPage.tsx +++ b/src/components/GamesPage.tsx @@ -11,29 +11,31 @@ interface FocusableItemProps { className?: string; focusKey?: string; scrollOptions?: ScrollIntoViewOptions; + active?: boolean; } -const FocusableItem = ({ onFocus, onEnterPress, children, className, focusKey, scrollOptions }: FocusableItemProps) => { +const FocusableItem = ({ onFocus, onEnterPress, children, className, focusKey, scrollOptions, active }: FocusableItemProps) => { const { mode } = useInputMode(); - const { ref, focused: rawFocused } = useFocusable({ - onFocus: () => { - onFocus(); - if (mode === 'gamepad') { - if (scrollOptions) { - ref.current?.scrollIntoView(scrollOptions); - } else { - ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); - } - } - }, + const { ref, focused: rawFocused, focusKey: internalFocusKey } = useFocusable({ + onFocus, onEnterPress, focusKey }); const focused = mode === 'gamepad' ? rawFocused : false; + useEffect(() => { + if (mode === 'gamepad' && (focused || active) && ref.current) { + if (scrollOptions) { + ref.current.scrollIntoView(scrollOptions); + } else { + ref.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); + } + } + }, [focused, active, mode, scrollOptions]); + return ( -
+
{children(focused)}
); @@ -44,6 +46,7 @@ const PlatformItem = ({ platform, active, onSelect }: { platform: Platform, acti onFocus={onSelect} className="shrink-0" focusKey={`PLATFORM_${platform.id}`} + active={active} > {(focused) => (
@@ -77,6 +80,7 @@ const GameListItem = ({ game, active, onFocus }: { game: Game, active: boolean, className="w-full" focusKey={`GAME_${game.id}`} scrollOptions={{ behavior: 'smooth', block: 'nearest' }} + active={active} > {(focused) => (
@@ -130,20 +134,24 @@ const AlphabetScroller = ({ }; export const GamesPage = () => { + const { mode } = useInputMode(); const [selectedPlatformId, setSelectedPlatformId] = useState(null); const [selectedGameId, setSelectedGameId] = useState(null); const [activeZone, setActiveZone] = useState<'platforms' | 'games' | 'alphabet' | 'details' | null>(null); + const detailTimeoutRef = React.useRef(null); const { ref: platformsRef, focusKey: platformsFocusKey } = useFocusable({ focusKey: 'PLATFORMS_ZONE', trackChildren: true, - preferredChildFocusKey: selectedPlatformId ? `PLATFORM_${selectedPlatformId}` : undefined + preferredChildFocusKey: selectedPlatformId ? `PLATFORM_${selectedPlatformId}` : undefined, + onBlur: () => setActiveZone(prev => prev === 'platforms' ? null : prev) }); const { ref: gamesRef, focusKey: gamesFocusKey } = useFocusable({ focusKey: 'GAMES_ZONE', trackChildren: true, - preferredChildFocusKey: selectedGameId ? `GAME_${selectedGameId}` : undefined + preferredChildFocusKey: selectedGameId ? `GAME_${selectedGameId}` : undefined, + onBlur: () => setActiveZone(prev => prev === 'games' ? null : prev) }); const { ref: alphabetRef, focusKey: alphabetFocusKey } = useFocusable({ @@ -192,10 +200,20 @@ export const GamesPage = () => { } }, [gamesQuery.data, selectedGameId]); - const handlePlatformFocus = (id: number) => { - setSelectedPlatformId(id); - setSelectedGameId(null); - setActiveZone('platforms'); + const handlePlatformFocus = (id: number, options: { skipZoneChange?: boolean } = {}) => { + if (mode === 'gamepad') { + if (!options.skipZoneChange) { + setActiveZone('platforms'); + } + if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current); + detailTimeoutRef.current = setTimeout(() => { + setSelectedPlatformId(id); + setSelectedGameId(null); + }, 150); + } else { + setSelectedPlatformId(id); + setSelectedGameId(null); + } }; // Alphabet Logic @@ -225,6 +243,97 @@ export const GamesPage = () => { } }; + const updateFocusFromScroll = (container: HTMLElement) => { + const rect = container.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + const candidates = Array.from(container.querySelectorAll('[data-focusable-key]')); + if (candidates.length === 0) return; + + let closestKey: string | null = null; + let minDistance = Infinity; + + candidates.forEach((el) => { + const elRect = el.getBoundingClientRect(); + const elCenterX = elRect.left + elRect.width / 2; + const elCenterY = elRect.top + elRect.height / 2; + + const dist = Math.sqrt(Math.pow(centerX - elCenterX, 2) + Math.pow(centerY - elCenterY, 2)); + if (dist < minDistance) { + minDistance = dist; + closestKey = el.getAttribute('data-focusable-key'); + } + }); + + if (closestKey && mode === 'gamepad') { + console.log("[MagneticFocus] Setting focus to:", closestKey); + setFocus(closestKey); + } + }; + + // Right analog stick scrolling — scrolls whichever zone is currently active + const SCROLL_SPEED = 12; // Increased speed for right stick + useEffect(() => { + const onAnalogScroll = (e: Event) => { + const { dx, dy } = (e as CustomEvent<{ dx: number; dy: number }>).detail; + let target: HTMLElement | null = null; + if (activeZone === 'platforms') target = platformsRef.current as HTMLElement | null; + else if (activeZone === 'games') target = gamesRef.current as HTMLElement | null; + else if (activeZone === 'alphabet') target = alphabetRef.current as HTMLElement | null; + else if (activeZone === 'details') target = detailsRef.current as HTMLElement | null; + + if (target) { + target.scrollLeft += dx * SCROLL_SPEED; + target.scrollTop += dy * SCROLL_SPEED; + + // Magnetic Focus: Update the focused element to match the new scroll position + if (activeZone === 'platforms' || activeZone === 'games' || activeZone === 'alphabet') { + updateFocusFromScroll(target); + } + } + }; + window.addEventListener('analogscroll', onAnalogScroll); + return () => window.removeEventListener('analogscroll', onAnalogScroll); + }, [activeZone, platformsRef, gamesRef, alphabetRef, detailsRef, mode]); + + // Bumper navigation — LB/RB to cycle platforms globally + useEffect(() => { + const onCycle = (direction: 'next' | 'prev') => { + if (filteredPlatforms.length <= 1) return; + + // Find current platform index (use most recent selectedPlatformId) + const currentIndex = filteredPlatforms.findIndex(p => p.id === selectedPlatformId); + if (currentIndex === -1 && selectedPlatformId !== null) return; + + let nextIndex = direction === 'next' ? currentIndex + 1 : currentIndex - 1; + + // Looping + if (nextIndex >= filteredPlatforms.length) nextIndex = 0; + if (nextIndex < 0) nextIndex = filteredPlatforms.length - 1; + + const nextPlatform = filteredPlatforms[nextIndex]; + + // Silent switch: don't change activeZone highlight unless we are already there + handlePlatformFocus(nextPlatform.id, { skipZoneChange: activeZone !== 'platforms' }); + + // If we are currently in the platforms zone, move focus visually to the new item + if (activeZone === 'platforms') { + setFocus(`PLATFORM_${nextPlatform.id}`); + } + }; + + const handlePrev = () => onCycle('prev'); + const handleNext = () => onCycle('next'); + + window.addEventListener('previousplatform', handlePrev); + window.addEventListener('nextplatform', handleNext); + return () => { + window.removeEventListener('previousplatform', handlePrev); + window.removeEventListener('nextplatform', handleNext); + }; + }, [filteredPlatforms, selectedPlatformId, activeZone, mode]); + if (platformsQuery.isLoading) return
Initializing Archives...
; return ( @@ -232,13 +341,13 @@ export const GamesPage = () => { {/* Header / Platforms */}
-
+
{filteredPlatforms.map(p => ( handlePlatformFocus(p.id)} + onSelect={() => handlePlatformFocus(p.id, { skipZoneChange: false })} /> ))}
@@ -251,7 +360,7 @@ export const GamesPage = () => {
-
+
{gamesQuery.isLoading ? (
Retrieving index...
) : ( @@ -260,7 +369,17 @@ export const GamesPage = () => { key={game.id} game={game} active={selectedGameId === game.id} - onFocus={() => { setSelectedGameId(game.id); setActiveZone('games'); }} + onFocus={() => { + if (mode === 'gamepad') { + setActiveZone('games'); + if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current); + detailTimeoutRef.current = setTimeout(() => { + setSelectedGameId(game.id); + }, 150); + } else { + setSelectedGameId(game.id); + } + }} /> )) )} @@ -282,7 +401,7 @@ export const GamesPage = () => { {/* Right Column: Game Details */} -
+
{detailsQuery.data ? (
@@ -322,7 +441,7 @@ export const GamesPage = () => {
setActiveZone('details')} + onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }} onEnterPress={() => console.log("Play Game", detailsQuery.data?.id)} className="shrink-0" focusKey="DETAILS_PLAY" @@ -336,7 +455,7 @@ export const GamesPage = () => { setActiveZone('details')} + onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }} onEnterPress={() => console.log("Download", detailsQuery.data?.id)} className="shrink-0" focusKey="DETAILS_DOWNLOAD" diff --git a/src/hooks/useGamepad.ts b/src/hooks/useGamepad.ts index ef719ef..c8acced 100644 --- a/src/hooks/useGamepad.ts +++ b/src/hooks/useGamepad.ts @@ -8,6 +8,8 @@ const BUTTON_DPAD_UP = 12; const BUTTON_DPAD_DOWN = 13; const BUTTON_DPAD_LEFT = 14; const BUTTON_DPAD_RIGHT = 15; +const BUTTON_LB = 4; +const BUTTON_RB = 5; const AXIS_THRESHOLD = 0.5; const INITIAL_DELAY_MS = 400; const REPEAT_RATE_MS = 100; @@ -86,6 +88,19 @@ export const useGamepad = () => { handleInput(`gp${i}_right`, right, () => navigateByDirection('right', {})); handleInput(`gp${i}_enter`, enter, () => dispatchEnter(), false); handleInput(`gp${i}_back`, back, () => dispatchEscape(), false); + + const lb = gp.buttons[BUTTON_LB]?.pressed; + const rb = gp.buttons[BUTTON_RB]?.pressed; + handleInput(`gp${i}_lb`, lb, () => window.dispatchEvent(new CustomEvent('previousplatform'))); + handleInput(`gp${i}_rb`, rb, () => window.dispatchEvent(new CustomEvent('nextplatform'))); + + // Right analog stick — scroll the currently focused list + const rsX = gp.axes[2] ?? 0; + const rsY = gp.axes[3] ?? 0; + if (Math.abs(rsX) > AXIS_THRESHOLD || Math.abs(rsY) > AXIS_THRESHOLD) { + setMode('gamepad'); + window.dispatchEvent(new CustomEvent('analogscroll', { detail: { dx: rsX, dy: rsY } })); + } } } catch (e) { console.error("Gamepad Polling Error", e); diff --git a/src/index.css b/src/index.css index 79bfd82..dd018fd 100644 --- a/src/index.css +++ b/src/index.css @@ -23,7 +23,7 @@ background-clip: content-box; } ::-webkit-scrollbar-thumb:hover { - background: var(--scrollbar-thumb-hover-color, rgba(255, 255, 255, 0.2)); + background: var(--scrollbar-thumb-color, rgba(255, 255, 255, 0.2)); background-clip: content-box; } @@ -46,10 +46,12 @@ body { .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } /* Setting the CSS variable on the scrollable container causes the pseudo-element - to inherit it, which is the correct way to drive scrollbar color via state. */ - .scrollbar-active { + to inherit it, which is the correct way to drive scrollbar color via state. + .scrollbar-active = gamepad / zone focus + .scrollbar-hoverable = mouse hover (same mechanism, same color) */ + .scrollbar-active, + .scrollbar-hoverable:hover { --scrollbar-thumb-color: var(--color-accent); - --scrollbar-thumb-hover-color: var(--color-accent); } /* Auto-scroll animation for long game titles when focused */