feat(reader): custom hardware-button page turning (#4177)
* feat(reader): add custom hardware-button page turning (#4139) Lets users bind hardware remote keys (media keys, D-pad/arrow keys) to previous/next page via a learn-mode capture UI in reader settings — an accessibility feature for page-turner remotes. - New global hardwarePageTurner system setting (enabled + key bindings). - hardwareKeys.ts: key normalization, matching, and page-turn resolution. - deviceStore: reference-counted media-key interception + learn mode. - usePagination: flips pages from bound media keys (native bridge) and D-pad/keyboard keys (DOM keydown), scoped to the active book and suppressed while the toolbar is visible. - Page Turner settings section on all platforms; web/desktop bind keys via DOM keydown only, native media-key interception stays mobile-only. - Android: intercept media + learn-mode keys in dispatchKeyEvent. - iOS: forward media keys via MPRemoteCommandCenter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(i18n): add and translate hardware page turner strings (#4139) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(reader): refine hardware page turner (#4139) - Handle book-iframe key events (iframe-keydown messages) so custom bindings work as soon as a book is open, not only after the settings panel has been shown. - Add Previous/Next Section bindings alongside the page bindings. - Rename the hardwareKeys util to keybinding. - Wire the Page Turner section into the settings Reset action. - Drop the focus ring on the capture buttons; BoxedList gains an optional description. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(i18n): translate page turner section and key strings (#4139) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,10 @@ import { ViewSettings } from '@/types/book';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { resolvePageTurn, normalizeDomKeyEvent, KeyCandidate } from '@/utils/keybinding';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { tauriGetWindowLogicalPosition } from '@/utils/window';
|
||||
import { getReadingRulerMoveDirection } from '../utils/readingRuler';
|
||||
@@ -109,7 +112,15 @@ export const usePagination = (
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getViewSettings, getViewState } = useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
|
||||
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
|
||||
const {
|
||||
acquireVolumeKeyInterception,
|
||||
releaseVolumeKeyInterception,
|
||||
acquirePageTurnerKeyInterception,
|
||||
releasePageTurnerKeyInterception,
|
||||
} = useDeviceControlStore();
|
||||
// Reactive subscription: drives the effect dependency array below. The
|
||||
// handlers themselves re-read via getState() to avoid stale closures.
|
||||
const hardwarePageTurner = useSettingsStore((s) => s.settings.hardwarePageTurner);
|
||||
|
||||
const handlePageFlip = async (
|
||||
msg: MessageEvent | CustomEvent | React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
@@ -247,6 +258,76 @@ export const usePagination = (
|
||||
}
|
||||
};
|
||||
|
||||
// Hardware page turner: media keys arrive via the `native-key-down`
|
||||
// event; D-pad / keyboard keys arrive either as a top-window `keydown`
|
||||
// or — when focus is inside a book iframe — as an `iframe-keydown`
|
||||
// postMessage (mirroring useShortcuts' unified window + iframe handling).
|
||||
// All resolve through the shared binding registry. Suppressed while the
|
||||
// toolbar is visible so D-pad keys keep driving toolbar spatial navigation.
|
||||
const handleHardwarePageTurn = (candidate: KeyCandidate): boolean => {
|
||||
const settings = useSettingsStore.getState().settings.hardwarePageTurner;
|
||||
if (!settings?.enabled) return false;
|
||||
if (useReaderStore.getState().hoveredBookKey) return false;
|
||||
|
||||
// Only the active book (the one driving the sidebar) responds, so a
|
||||
// single key press doesn't flip every book open in a parallel view.
|
||||
if (useSidebarStore.getState().sideBarBookKey !== bookKey) return false;
|
||||
|
||||
const viewState = getViewState(bookKey);
|
||||
if (!viewState?.inited) return false;
|
||||
|
||||
const action = resolvePageTurn(settings, candidate);
|
||||
if (!action) return false;
|
||||
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const side = action === 'pagePrev' || action === 'sectionPrev' ? 'up' : 'down';
|
||||
const mode = action === 'sectionPrev' || action === 'sectionNext' ? 'section' : 'page';
|
||||
setHoveredBookKey('');
|
||||
if (
|
||||
mode === 'page' &&
|
||||
viewSettings?.readingRulerEnabled &&
|
||||
eventDispatcher.dispatchSync('reading-ruler-move', {
|
||||
bookKey,
|
||||
direction: getReadingRulerMoveDirection(side, viewRef.current?.book.dir),
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
viewPagination(viewRef.current, viewSettings, side, mode);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleHardwareNativeKey = (msg: CustomEvent) => {
|
||||
const keyName = msg.detail?.keyName;
|
||||
if (typeof keyName !== 'string') return;
|
||||
handleHardwarePageTurn({ source: 'native', id: keyName });
|
||||
};
|
||||
|
||||
const handleHardwareDomKey = (event: KeyboardEvent | MessageEvent) => {
|
||||
let candidate: KeyCandidate;
|
||||
if (event instanceof KeyboardEvent) {
|
||||
if (event.repeat) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '')) {
|
||||
return;
|
||||
}
|
||||
candidate = normalizeDomKeyEvent(event);
|
||||
} else if (event.data?.type === 'iframe-keydown' && event.data.bookKey === bookKey) {
|
||||
const id = event.data.code || event.data.key;
|
||||
if (typeof id !== 'string' || !id) return;
|
||||
candidate = { source: 'dom', id };
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleHardwarePageTurn(candidate)) {
|
||||
// Stop `useShortcuts` from also paging on this key — capture-phase
|
||||
// for the window keydown, registration order for the iframe message.
|
||||
if (event instanceof KeyboardEvent) event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.isMobileApp) return;
|
||||
|
||||
@@ -265,6 +346,45 @@ export const usePagination = (
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Hardware page turner: native-key + DOM-key listeners and native
|
||||
// media-key interception, re-evaluated whenever the setting changes.
|
||||
useEffect(() => {
|
||||
const hasNativeBinding =
|
||||
hardwarePageTurner?.bindings.pagePrev?.source === 'native' ||
|
||||
hardwarePageTurner?.bindings.pageNext?.source === 'native' ||
|
||||
hardwarePageTurner?.bindings.sectionPrev?.source === 'native' ||
|
||||
hardwarePageTurner?.bindings.sectionNext?.source === 'native';
|
||||
const needsNativeInterception =
|
||||
!!appService?.isMobileApp && !!hardwarePageTurner?.enabled && hasNativeBinding;
|
||||
|
||||
if (needsNativeInterception) {
|
||||
acquirePageTurnerKeyInterception();
|
||||
}
|
||||
if (hasNativeBinding) {
|
||||
eventDispatcher.on('native-key-down', handleHardwareNativeKey);
|
||||
}
|
||||
window.addEventListener('keydown', handleHardwareDomKey, true);
|
||||
window.addEventListener('message', handleHardwareDomKey);
|
||||
|
||||
return () => {
|
||||
if (needsNativeInterception) {
|
||||
releasePageTurnerKeyInterception();
|
||||
}
|
||||
if (hasNativeBinding) {
|
||||
eventDispatcher.off('native-key-down', handleHardwareNativeKey);
|
||||
}
|
||||
window.removeEventListener('keydown', handleHardwareDomKey, true);
|
||||
window.removeEventListener('message', handleHardwareDomKey);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
hardwarePageTurner?.enabled,
|
||||
hardwarePageTurner?.bindings.pagePrev?.source,
|
||||
hardwarePageTurner?.bindings.pageNext?.source,
|
||||
hardwarePageTurner?.bindings.sectionPrev?.source,
|
||||
hardwarePageTurner?.bindings.sectionNext?.source,
|
||||
]);
|
||||
|
||||
// Touch swipe page flip for fixed-layout books — registered as a touch interceptor
|
||||
// so it participates in the priority-based consumption chain.
|
||||
useTouchInterceptor(
|
||||
|
||||
Reference in New Issue
Block a user