* feat(android): add D-pad navigation for Android TV remote controller Add basic D-pad/arrow key navigation support for Android TV Bluetooth remote controllers, covering the full flow: library grid browsing, reader page turning, toolbar interaction, and TTS toggle. Library: - Custom spatial navigation hook for grid D-pad navigation - Arrow keys move between BookshelfItem elements with auto column detection - ArrowDown from header enters the bookshelf grid Reader: - Enter key toggles header/footer toolbar visibility - Left/Right navigate between toolbar buttons, Up/Down moves between header and footer bars - Back button dismisses toolbar (with blur) before sidebar/library - Focus-probe technique for reliable button visibility detection across fixed/hidden containers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: only auto-focus toolbar buttons on keyboard activation, not mouse hover Track pointer activity to distinguish keyboard vs mouse toolbar activation. When the toolbar appears due to mouse hover, skip auto-focus to prevent unwanted focus outlines on desktop. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: track keyboard events instead of pointer events for auto-focus guard Pointer events from iframes don't bubble to the main document, so tracking pointermove/pointerdown missed hover interactions over book content. Track keydown instead — auto-focus only when a recent keyboard event (Enter key) triggered the toolbar, not mouse hover. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4.1 KiB
name, description, type
| name | description | type |
|---|---|---|
| D-pad Navigation Design | Android TV / Bluetooth remote D-pad navigation architecture, key files, and pitfalls encountered during implementation | project |
D-pad Navigation Architecture
D-pad support enables Bluetooth remote controller navigation on Android TV (and keyboard arrow navigation on desktop).
Key Files
src/app/reader/hooks/useSpatialNavigation.ts— Reader toolbar D-pad navigation. Left/Right navigates between buttons, Up/Down moves between header↔footer. Auto-focuses first button on show. Uses focus-probe technique for visibility detection.src/app/library/hooks/useSpatialNavigation.ts— Library grid D-pad navigation. Arrow keys move between BookshelfItem elements. ArrowDown from outside bookshelf (e.g. header) enters the grid via window-level listener.src/helpers/shortcuts.ts—onToggleToolbar(Enter key) toggles reader toolbar visibility.src/app/reader/hooks/useBookShortcuts.ts—toggleToolbarhandler shows/hides header+footer bars. Skips when a<button>is focused (lets native click fire).src/__tests__/hooks/useSpatialNavigation.test.tsx— Unit tests for reader toolbar navigation.
Design Decisions
- No third-party library: Tried
@noriginmedia/norigin-spatial-navigationbut it failed due to init timing issues (React child effects run before parent effects) and conflicts with the existinguseShortcutssystem. Custom solution is simpler and more reliable. - Two
useSpatialNavigationhooks: Same name in different directories — library version handles grid navigation, reader version handles toolbar button navigation. Different navigation patterns but same concept. - Platform-agnostic hooks: Both
useSpatialNavigationhooks work on all platforms, not just Android. - Focus-probe for visibility:
offsetParentis unreliable for detecting visible buttons (returns null insideposition: fixedcontainers on mobile). Instead, trybtn.focus()and check ifdocument.activeElement === btn— this correctly handles all hiding methods (display:none, visibility:hidden, fixed positioning).
Pitfalls
-
WebView spatial navigation conflict: Android WebView has built-in spatial navigation that intercepts D-pad arrow keys and moves DOM focus between
tabIndex>=0elements. AddedtabIndex={-1}to non-interactive overlay elements (HeaderBar trigger, ProgressBar, FooterBar trigger, SectionInfo) to prevent focus theft. -
eventDispatcher.dispatchSyncshort-circuits: When multiple handlers are registered fornative-key-down, the first handler returningtruestops propagation. The FooterBar's Back handler fires before the Reader's. Both must independently callblur()— can't rely on the Reader's handler running. -
Must blur on toolbar dismiss: When Back/Escape dismisses the toolbar, the focused button must be blurred. Otherwise
document.activeElementremains a hidden button, andtoggleToolbarskips Enter whenactiveElement.tagName === 'BUTTON'. Blur is called in FooterBar's handleKeyDown (for Back and Escape) and in Reader's handleKeyDown. -
Arrow key trapping must use
stopPropagation: Without it, arrow keys bubble towindowwhereuseShortcutshandles them as page turns. The toolbar keydown handler on the container div callse.stopPropagation()+e.preventDefault()to prevent this. -
Library grid needs window-level listener: The bookshelf container keydown handler only fires when focus is inside it. A separate
windowkeydown listener handles ArrowDown from the header into the grid (when focus is outside the container). -
Auto-focus race on toolbar show: Both header and footer bars auto-focus their first button when
isVisiblebecomes true simultaneously. The last effect to run wins. This is acceptable — user can navigate between them with Up/Down. -
offsetParentnull in fixed containers: On mobile,.footer-barusesposition: fixed. All child buttons haveoffsetParent === null, makingoffsetParent-based visibility checks useless. The focus-probe approach (try focus, check activeElement) is the reliable alternative.