forked from akai/readest
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user