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
@@ -66,6 +66,8 @@ class SetSystemUIVisibilityRequestArgs {
class InterceptKeysRequestArgs {
var volumeKeys: Boolean? = null
var backKey: Boolean? = null
var pageTurnerKeys: Boolean? = null
var learnMode: Boolean? = null
}
@InvokeArg
@@ -115,6 +117,8 @@ data class PurchaseData(
interface KeyDownInterceptor {
fun interceptVolumeKeys(enabled: Boolean)
fun interceptBackKey(enabled: Boolean)
fun interceptPageTurnerKeys(enabled: Boolean)
fun setKeyLearnMode(enabled: Boolean)
}
@TauriPlugin(
@@ -401,16 +405,11 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
fun intercept_keys(invoke: Invoke) {
val args = invoke.parseArgs(InterceptKeysRequestArgs::class.java)
if (activity is KeyDownInterceptor) {
when (args.backKey) {
true -> (activity as KeyDownInterceptor).interceptBackKey(true)
false -> (activity as KeyDownInterceptor).interceptBackKey(false)
else -> {}
}
when (args.volumeKeys) {
true -> (activity as KeyDownInterceptor).interceptVolumeKeys(true)
false -> (activity as KeyDownInterceptor).interceptVolumeKeys(false)
else -> {}
}
val interceptor = activity as KeyDownInterceptor
args.backKey?.let { interceptor.interceptBackKey(it) }
args.volumeKeys?.let { interceptor.interceptVolumeKeys(it) }
args.pageTurnerKeys?.let { interceptor.interceptPageTurnerKeys(it) }
args.learnMode?.let { interceptor.setKeyLearnMode(it) }
} else {
Log.e("NativeBridgePlugin", "Activity does not implement KeyDownInterceptor")
}
@@ -43,6 +43,8 @@ class SetSystemUIVisibilityRequestArgs: Decodable {
class InterceptKeysRequestArgs: Decodable {
let backKey: Bool?
let volumeKeys: Bool?
let pageTurnerKeys: Bool?
let learnMode: Bool?
}
class LockScreenOrientationRequestArgs: Decodable {
@@ -192,6 +194,44 @@ class VolumeKeyHandler: NSObject {
}
}
class MediaKeyHandler {
private weak var webView: WKWebView?
private var registered = false
private let commandCenter = MPRemoteCommandCenter.shared()
func start(webView: WKWebView) {
self.webView = webView
if registered { return }
registered = true
commandCenter.nextTrackCommand.isEnabled = true
commandCenter.previousTrackCommand.isEnabled = true
commandCenter.nextTrackCommand.addTarget { [weak self] _ in
self?.forward("MediaNext")
return .success
}
commandCenter.previousTrackCommand.addTarget { [weak self] _ in
self?.forward("MediaPrevious")
return .success
}
logger.log("MediaKeyHandler: started")
}
func stop() {
if !registered { return }
registered = false
commandCenter.nextTrackCommand.removeTarget(nil)
commandCenter.previousTrackCommand.removeTarget(nil)
logger.log("MediaKeyHandler: stopped")
}
private func forward(_ name: String) {
DispatchQueue.main.async { [weak self] in
self?.webView?.evaluateJavaScript(
"try { window.onNativeKeyDown('\(name)', 0); } catch (_) {}", completionHandler: nil)
}
}
}
class WebViewLifecycleManager: NSObject {
private weak var webView: WKWebView?
private var originalNavigationDelegate: WKNavigationDelegate?
@@ -503,6 +543,8 @@ class NativeBridgePlugin: Plugin {
private struct AssociatedKeys {
static var volumeKeyHandler = "volumeKeyHandler"
static var interceptingVolumeKeys = "interceptingVolumeKeys"
static var mediaKeyHandler = "mediaKeyHandler"
static var mediaKeyState = "mediaKeyState"
}
private var volumeKeyHandler: VolumeKeyHandler? {
@@ -526,6 +568,44 @@ class NativeBridgePlugin: Plugin {
}
}
private var mediaKeyHandler: MediaKeyHandler? {
get {
return objc_getAssociatedObject(self, &AssociatedKeys.mediaKeyHandler) as? MediaKeyHandler
}
set {
objc_setAssociatedObject(
self, &AssociatedKeys.mediaKeyHandler, newValue, .OBJC_ASSOCIATION_RETAIN)
}
}
// Bit 0 = pageTurnerKeys interception, bit 1 = learn mode.
private var mediaKeyState: Int {
get {
return objc_getAssociatedObject(self, &AssociatedKeys.mediaKeyState) as? Int ?? 0
}
set {
objc_setAssociatedObject(
self, &AssociatedKeys.mediaKeyState, newValue, .OBJC_ASSOCIATION_RETAIN)
}
}
private func updateMediaKeyHandler() {
let shouldRun = mediaKeyState != 0
if shouldRun {
if mediaKeyHandler == nil {
mediaKeyHandler = MediaKeyHandler()
}
if let webView = self.webView {
mediaKeyHandler?.start(webView: webView)
} else {
logger.warning("Cannot start media key handler: webView is nil")
}
} else {
mediaKeyHandler?.stop()
mediaKeyHandler = nil
}
}
@objc public func use_background_audio(_ invoke: Invoke) {
do {
let args = try invoke.parseArgs(UseBackgroundAudioRequestArgs.self)
@@ -633,6 +713,19 @@ class NativeBridgePlugin: Plugin {
}
}
}
if let pageTurnerKeys = args.pageTurnerKeys {
mediaKeyState = pageTurnerKeys ? (mediaKeyState | 1) : (mediaKeyState & ~1)
}
if let learnMode = args.learnMode {
mediaKeyState = learnMode ? (mediaKeyState | 2) : (mediaKeyState & ~2)
}
if args.pageTurnerKeys != nil || args.learnMode != nil {
DispatchQueue.main.async { [weak self] in
self?.updateMediaKeyHandler()
}
}
invoke.resolve()
} catch {
invoke.reject(error.localizedDescription)