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:
Huang Xin
2026-05-16 01:57:33 +08:00
committed by GitHub
parent f5e729a174
commit 787bbf2103
48 changed files with 1569 additions and 154 deletions
@@ -1,7 +1,6 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
@@ -14,6 +13,7 @@ import { SettingsPanelPanelProp } from './SettingsDialog';
import { annotationToolQuickActions } from '@/app/reader/components/annotator/AnnotationTools';
import { BoxedList, SettingsRow, SettingsSelect, SettingsSwitchRow } from './primitives';
import NumberInput from './NumberInput';
import PageTurnerSettings from './PageTurnerSettings';
const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
const _ = useTranslation();
@@ -22,7 +22,6 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
const { getBookData } = useBookDataStore();
const { settings } = useSettingsStore();
const { applyEinkMode } = useEinkMode();
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
const bookData = getBookData(bookKey);
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
@@ -30,7 +29,6 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
const [noContinuousScroll, setNoContinuousScroll] = useState(viewSettings.noContinuousScroll);
const [scrollingOverlap, setScrollingOverlap] = useState(viewSettings.scrollingOverlap);
const [hideScrollbar, setHideScrollbar] = useState(viewSettings.hideScrollbar || false);
const [volumeKeysToFlip, setVolumeKeysToFlip] = useState(viewSettings.volumeKeysToFlip);
const [showPaginationButtons, setShowPaginationButtons] = useState(
viewSettings.showPaginationButtons,
);
@@ -53,6 +51,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
const [allowScript, setAllowScript] = useState(viewSettings.allowScript);
const resetToDefaults = useResetViewSettings();
const pageTurnerResetRef = useRef<() => void>(() => {});
const handleReset = () => {
resetToDefaults({
@@ -60,7 +59,6 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
noContinuousScroll: setNoContinuousScroll,
scrollingOverlap: setScrollingOverlap,
hideScrollbar: setHideScrollbar,
volumeKeysToFlip: setVolumeKeysToFlip,
showPaginationButtons: setShowPaginationButtons,
disableClick: setIsDisableClick,
swapClickArea: setSwapClickArea,
@@ -72,6 +70,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
enableAnnotationQuickActions: setEnableAnnotationQuickActions,
copyToNotebook: setCopyToNotebook,
});
pageTurnerResetRef.current();
};
useEffect(() => {
@@ -113,18 +112,6 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollingOverlap]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'volumeKeysToFlip', volumeKeysToFlip, false, false);
if (appService?.isMobileApp) {
if (volumeKeysToFlip) {
acquireVolumeKeyInterception();
} else {
releaseVolumeKeyInterception();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [volumeKeysToFlip]);
useEffect(() => {
saveViewSettings(
envConfig,
@@ -300,13 +287,6 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
onChange={() => setIsDisableDoubleClick(!isDisableDoubleClick)}
data-setting-id='settings.control.disableDoubleClick'
/>
{appService?.isMobileApp && (
<SettingsSwitchRow
label={_('Volume Keys for Page Flip')}
checked={volumeKeysToFlip}
onChange={() => setVolumeKeysToFlip(!volumeKeysToFlip)}
/>
)}
<SettingsSwitchRow
label={_('Show Page Navigation Buttons')}
checked={showPaginationButtons}
@@ -315,6 +295,13 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
/>
</BoxedList>
<PageTurnerSettings
bookKey={bookKey}
onRegisterReset={(fn) => {
pageTurnerResetRef.current = fn;
}}
/>
<BoxedList
title={_('Annotation Tools')}
data-setting-id='settings.control.enableQuickActions'
@@ -0,0 +1,201 @@
import React, { useEffect, useRef, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { saveSysSettings, saveViewSettings } from '@/helpers/settings';
import { useResetViewSettings } from '@/hooks/useResetSettings';
import { eventDispatcher } from '@/utils/event';
import {
normalizeNativeKey,
normalizeDomKeyEvent,
PAGE_TURN_ACTIONS,
PageTurnAction,
} from '@/utils/keybinding';
import { HardwarePageTurnerSettings, KeyBinding } from '@/types/settings';
import { BoxedList, SettingsRow, SettingsSwitchRow } from './primitives';
import { useReaderStore } from '@/store/readerStore';
type Slot = PageTurnAction;
const LEARN_TIMEOUT_MS = 8000;
interface PageTurnerSettingsProps {
bookKey: string;
onRegisterReset?: (resetFn: () => void) => void;
}
const PageTurnerSettings: React.FC<PageTurnerSettingsProps> = ({ bookKey, onRegisterReset }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { getViewSettings } = useReaderStore();
const { setKeyLearnMode, acquireVolumeKeyInterception, releaseVolumeKeyInterception } =
useDeviceControlStore();
const { settings } = useSettingsStore();
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
const resetToDefaults = useResetViewSettings();
const [volumeKeysToFlip, setVolumeKeysToFlip] = useState(viewSettings.volumeKeysToFlip);
const [config, setConfig] = useState<HardwarePageTurnerSettings>(settings.hardwarePageTurner);
const configRef = useRef(config);
configRef.current = config;
const [listening, setListening] = useState<Slot | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const persist = (next: HardwarePageTurnerSettings) => {
setConfig(next);
saveSysSettings(envConfig, 'hardwarePageTurner', next);
};
// Native key interception exists only on mobile; on web and desktop
// learn mode relies on standard DOM keydown events alone.
const setNativeLearnMode = (enabled: boolean) => {
if (appService?.isMobileApp) setKeyLearnMode(enabled);
};
const stopListening = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = null;
setNativeLearnMode(false);
setListening(null);
};
const captureBinding = (slot: Slot, binding: KeyBinding) => {
const current = configRef.current;
const bindings = { ...current.bindings, [slot]: binding };
// A key can drive only one action — clear it from every other slot.
for (const other of PAGE_TURN_ACTIONS) {
if (
other !== slot &&
bindings[other]?.source === binding.source &&
bindings[other]?.id === binding.id
) {
bindings[other] = null;
}
}
persist({ ...current, bindings });
stopListening();
};
useEffect(() => {
if (!listening) return;
const onNativeKey = (msg: CustomEvent) => {
const keyName = msg.detail?.keyName;
if (typeof keyName !== 'string') return;
// Back, and volume keys (which have their own dedicated page-flip
// toggle), are not bindable here — binding them would be inert or
// would double-fire alongside the volume-keys handler.
if (keyName === 'Back' || keyName === 'VolumeUp' || keyName === 'VolumeDown') return;
captureBinding(listening, normalizeNativeKey(keyName));
};
const onDomKey = (event: KeyboardEvent) => {
if (event.repeat) return;
event.preventDefault();
event.stopImmediatePropagation();
captureBinding(listening, normalizeDomKeyEvent(event));
};
setNativeLearnMode(true);
eventDispatcher.on('native-key-down', onNativeKey);
window.addEventListener('keydown', onDomKey, true);
timeoutRef.current = setTimeout(stopListening, LEARN_TIMEOUT_MS);
return () => {
eventDispatcher.off('native-key-down', onNativeKey);
window.removeEventListener('keydown', onDomKey, true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = null;
setNativeLearnMode(false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listening]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'volumeKeysToFlip', volumeKeysToFlip, false, false);
if (appService?.isMobileApp) {
if (volumeKeysToFlip) {
acquireVolumeKeyInterception();
} else {
releaseVolumeKeyInterception();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [volumeKeysToFlip]);
useEffect(() => {
onRegisterReset?.(() => resetToDefaults({ volumeKeysToFlip: setVolumeKeysToFlip }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!config.enabled && listening) stopListening();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.enabled]);
const renderSlot = (slot: Slot, label: string) => {
const binding = config.bindings[slot];
const isListening = listening === slot;
return (
<SettingsRow
label={label}
disabled={!config.enabled}
data-setting-id={`settings.control.pageTurner.${slot}`}
>
<div className='flex items-center gap-5'>
{binding && !isListening && (
<button
type='button'
className='text-base-content/70 hover:text-base-content text-end text-[0.85em] focus:outline-none'
disabled={!config.enabled}
aria-label={`${_('Clear')}: ${label}`}
onClick={() => persist({ ...config, bindings: { ...config.bindings, [slot]: null } })}
>
{_('Clear')}
</button>
)}
<button
type='button'
className='text-base-content/70 hover:text-base-content py-1 text-end text-[0.85em] focus:outline-none'
disabled={!config.enabled}
aria-pressed={isListening}
aria-label={`${label}: ${isListening ? _('Listening…') : _('Set key')}`}
onClick={() => (isListening ? stopListening() : setListening(slot))}
>
{isListening ? _('Listening…') : binding ? _(binding.label) : _('Set key')}
</button>
</div>
</SettingsRow>
);
};
return (
<div className='space-y-2'>
<BoxedList
title={_('Page Turner')}
data-setting-id='settings.control.pageTurner'
description={_(
'Press a button on your remote controller or keyboard after tapping "Set key".',
)}
>
{appService?.isMobileApp && (
<SettingsSwitchRow
label={_('Use Volume Keys')}
checked={volumeKeysToFlip}
onChange={() => setVolumeKeysToFlip(!volumeKeysToFlip)}
/>
)}
<SettingsSwitchRow
label={_('Custom Page Turner')}
checked={config.enabled}
onChange={() => persist({ ...config, enabled: !config.enabled })}
/>
{renderSlot('pagePrev', _('Previous Page'))}
{renderSlot('pageNext', _('Next Page'))}
{renderSlot('sectionPrev', _('Previous Section'))}
{renderSlot('sectionNext', _('Next Section'))}
</BoxedList>
</div>
);
};
export default PageTurnerSettings;
@@ -42,14 +42,14 @@ const BoxedList: React.FC<BoxedListProps> = ({
return (
<div className={clsx('w-full', className)} data-setting-id={dataSettingId}>
{title && <SectionTitle className='mb-2'>{title}</SectionTitle>}
{description && (
<p className='text-base-content/65 -mt-1 mb-2 text-[0.7em] leading-relaxed'>
{description}
</p>
)}
<div className={clsx('card eink-bordered border-base-200 bg-base-100 border', cardClassName)}>
<div className={clsx('divide-base-200 divide-y ps-4', innerClassName)}>{children}</div>
</div>
{description && (
<p className='text-base-content/65 mb-2 mt-1 ps-4 text-[0.8em] leading-relaxed'>
{description}
</p>
)}
</div>
);
};