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:
+9
-10
@@ -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")
|
||||
}
|
||||
|
||||
+93
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user